cap-mcp-guard 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@
3
3
  const { buildContext } = require('./context');
4
4
  const { evaluate } = require('../policy/evaluator');
5
5
  const { maskFields } = require('../policy/masking');
6
+ const { generatePseudonym } = require('../policy/pseudonym');
6
7
 
7
8
  /**
8
9
  * Reads W3C Trace Context fields propagated by the MCP runtime.
@@ -74,6 +75,30 @@ function applyMask(results, fieldsToMask) {
74
75
  });
75
76
  }
76
77
 
78
+ /**
79
+ * Replaces each listed field's real value with a deterministic pseudonym (see
80
+ * lib/policy/pseudonym.js) — same real value always produces the same fake one, so an
81
+ * AI agent can still tell rows apart without ever seeing the real data. Mutates `results`
82
+ * in place, same convention as applyMask().
83
+ *
84
+ * @param {object|object[]} results
85
+ * @param {{field: string, type: string}[]} fieldsToPseudonymize
86
+ * @param {string} secret required whenever fieldsToPseudonymize is non-empty (see
87
+ * lib/adapters/cap.js, which resolves and validates this before attachInterceptor runs)
88
+ */
89
+ function applyPseudonymize(results, fieldsToPseudonymize, secret) {
90
+ const items = Array.isArray(results) ? results : [results];
91
+
92
+ items.forEach((item) => {
93
+ if (!item || typeof item !== 'object') return;
94
+ for (const { field, type } of fieldsToPseudonymize) {
95
+ if (Object.prototype.hasOwnProperty.call(item, field)) {
96
+ item[field] = generatePseudonym(field, item[field], type, secret);
97
+ }
98
+ }
99
+ });
100
+ }
101
+
77
102
  /**
78
103
  * Attaches request interception to a CAP service instance, producing a
79
104
  * guard context (see context.js) for every request that passes through it.
@@ -98,13 +123,15 @@ function applyMask(results, fieldsToMask) {
98
123
  * each request completes, regardless of mode — this is the extension
99
124
  * point M5 (audit log) uses
100
125
  * @param {() => string} [options.now] injectable clock, forwarded to buildContext
126
+ * @param {string} [options.pseudonymSecret] required whenever any entity configures
127
+ * `pseudonymize` — resolved and validated one layer up in lib/adapters/cap.js
101
128
  */
102
129
  function attachInterceptor(srv, options = {}) {
103
130
  if (!srv || typeof srv.before !== 'function' || typeof srv.after !== 'function') {
104
131
  throw new Error('attachInterceptor requires a CAP service exposing before()/after() hooks');
105
132
  }
106
133
 
107
- const { onContext, policyDefinition, onDecision, now } = options;
134
+ const { onContext, policyDefinition, onDecision, now, pseudonymSecret } = options;
108
135
  const clockDeps = now ? { now } : {};
109
136
 
110
137
  srv.before('*', (req) => {
@@ -146,6 +173,10 @@ function attachInterceptor(srv, options = {}) {
146
173
  applyMask(results, decision.fieldsToMask);
147
174
  }
148
175
 
176
+ if (decision.mode === 'enforce' && decision.fieldsToPseudonymize.length > 0) {
177
+ applyPseudonymize(results, decision.fieldsToPseudonymize, pseudonymSecret);
178
+ }
179
+
149
180
  if (typeof onDecision === 'function') {
150
181
  onDecision(decision, context, req);
151
182
  }
package/lib/index.js CHANGED
@@ -2,6 +2,5 @@
2
2
 
3
3
  const { buildContext } = require('./core/context');
4
4
  const { attachInterceptor } = require('./core/interceptor');
5
- const { odataMcpGuard } = require('./adapters/odata');
6
5
 
7
- module.exports = { buildContext, attachInterceptor, odataMcpGuard };
6
+ module.exports = { buildContext, attachInterceptor };
@@ -1,106 +1,164 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const yaml = require('js-yaml');
5
-
6
- const VALID_MODES = ['enforce', 'observe'];
7
- const DEFAULT_SOURCE = 'cap-mcp-guard.yaml';
8
-
9
- function typeOf(value) {
10
- if (Array.isArray(value)) return 'array';
11
- if (value === null) return 'null';
12
- return typeof value;
13
- }
14
-
15
- function validateEntityConfig(name, raw) {
16
- const config = raw ?? {};
17
-
18
- if (typeof config !== 'object' || Array.isArray(config)) {
19
- throw new Error(`entities.${name} must be a mapping, got ${typeOf(config)}`);
20
- }
21
-
22
- const { mask, maxRows, allowTools } = config;
23
-
24
- if (mask !== undefined && !Array.isArray(mask)) {
25
- throw new Error(`entities.${name}.mask must be an array, got ${typeOf(mask)}`);
26
- }
27
-
28
- if (allowTools !== undefined && !Array.isArray(allowTools)) {
29
- throw new Error(`entities.${name}.allowTools must be an array, got ${typeOf(allowTools)}`);
30
- }
31
-
32
- if (maxRows !== undefined) {
33
- if (typeof maxRows !== 'number' || Number.isNaN(maxRows)) {
34
- throw new Error(`entities.${name}.maxRows must be a number, got ${typeOf(maxRows)}`);
35
- }
36
- if (maxRows < 0) {
37
- throw new Error(`entities.${name}.maxRows must not be negative (got ${maxRows})`);
38
- }
39
- }
40
-
41
- return { mask, maxRows, allowTools };
42
- }
43
-
44
- /**
45
- * Parses and validates a cap-mcp-guard.yaml document (already read into a
46
- * string) into a PolicyDefinition — the source-agnostic shape the policy
47
- * engine consumes. Pure: no filesystem access.
48
- *
49
- * @param {string} yamlString
50
- * @param {object} [opts]
51
- * @param {string} [opts.source] label used in error messages (defaults to
52
- * the conventional file name; loadConfig() passes the real path instead)
53
- * @returns {{ mode: 'enforce'|'observe', entities: object }}
54
- */
55
- function parseConfig(yamlString, opts = {}) {
56
- const { source = DEFAULT_SOURCE } = opts;
57
-
58
- let doc;
59
- try {
60
- doc = yaml.load(yamlString);
61
- } catch (err) {
62
- throw new Error(`Failed to parse ${source}: ${err.message}`);
63
- }
64
-
65
- if (doc === undefined || doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
66
- throw new Error(`${source}: root must be a mapping with a "mode" key`);
67
- }
68
-
69
- if (!VALID_MODES.includes(doc.mode)) {
70
- throw new Error(`"mode" must be one of ${VALID_MODES.join(', ')} (got ${JSON.stringify(doc.mode)})`);
71
- }
72
-
73
- const rawEntities = doc.entities ?? {};
74
- if (typeof rawEntities !== 'object' || Array.isArray(rawEntities)) {
75
- throw new Error(`"entities" must be a mapping of entity name to config, got ${typeOf(rawEntities)}`);
76
- }
77
-
78
- const entities = {};
79
- for (const [name, raw] of Object.entries(rawEntities)) {
80
- entities[name] = validateEntityConfig(name, raw);
81
- }
82
-
83
- return { mode: doc.mode, entities };
84
- }
85
-
86
- /**
87
- * Reads cap-mcp-guard.yaml from disk and parses it via parseConfig().
88
- *
89
- * @param {string} path
90
- * @returns {{ mode: 'enforce'|'observe', entities: object }}
91
- */
92
- function loadConfig(path) {
93
- let content;
94
- try {
95
- content = fs.readFileSync(path, 'utf8');
96
- } catch (err) {
97
- if (err.code === 'ENOENT') {
98
- throw new Error(`cap-mcp-guard.yaml not found at ${path}`);
99
- }
100
- throw err;
101
- }
102
-
103
- return parseConfig(content, { source: path });
104
- }
105
-
106
- module.exports = { loadConfig, parseConfig };
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+
5
+ const { PSEUDONYM_TYPES } = require('./pseudonym');
6
+
7
+ const VALID_MODES = ['enforce', 'observe'];
8
+ const CONFIG_KEY = 'cap-mcp-guard';
9
+ const NOT_CONFIGURED_PREFIX = 'cap-mcp-guard not configured';
10
+
11
+ function typeOf(value) {
12
+ if (Array.isArray(value)) return 'array';
13
+ if (value === null) return 'null';
14
+ return typeof value;
15
+ }
16
+
17
+ function validatePseudonymizeEntry(entityName, entry) {
18
+ if (typeof entry === 'string') {
19
+ return { field: entry, type: 'opaque' };
20
+ }
21
+
22
+ if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
23
+ const { field, type = 'opaque' } = entry;
24
+
25
+ if (typeof field !== 'string' || !field) {
26
+ throw new Error(`entities.${entityName}.pseudonymize entries must have a "field" string`);
27
+ }
28
+ if (!PSEUDONYM_TYPES.includes(type)) {
29
+ throw new Error(
30
+ `entities.${entityName}.pseudonymize.${field}.type must be one of ${PSEUDONYM_TYPES.join(', ')} (got ${JSON.stringify(type)})`
31
+ );
32
+ }
33
+ return { field, type };
34
+ }
35
+
36
+ throw new Error(
37
+ `entities.${entityName}.pseudonymize entries must be a string or a {field, type} object, got ${typeOf(entry)}`
38
+ );
39
+ }
40
+
41
+ function validateEntityConfig(name, raw) {
42
+ const config = raw ?? {};
43
+
44
+ if (typeof config !== 'object' || Array.isArray(config)) {
45
+ throw new Error(`entities.${name} must be a mapping, got ${typeOf(config)}`);
46
+ }
47
+
48
+ const { mask, maxRows, allowTools, pseudonymize: rawPseudonymize } = config;
49
+
50
+ if (mask !== undefined && !Array.isArray(mask)) {
51
+ throw new Error(`entities.${name}.mask must be an array, got ${typeOf(mask)}`);
52
+ }
53
+
54
+ if (allowTools !== undefined && !Array.isArray(allowTools)) {
55
+ throw new Error(`entities.${name}.allowTools must be an array, got ${typeOf(allowTools)}`);
56
+ }
57
+
58
+ if (maxRows !== undefined) {
59
+ if (typeof maxRows !== 'number' || Number.isNaN(maxRows)) {
60
+ throw new Error(`entities.${name}.maxRows must be a number, got ${typeOf(maxRows)}`);
61
+ }
62
+ if (maxRows < 0) {
63
+ throw new Error(`entities.${name}.maxRows must not be negative (got ${maxRows})`);
64
+ }
65
+ }
66
+
67
+ let pseudonymize;
68
+ if (rawPseudonymize !== undefined) {
69
+ if (!Array.isArray(rawPseudonymize)) {
70
+ throw new Error(`entities.${name}.pseudonymize must be an array, got ${typeOf(rawPseudonymize)}`);
71
+ }
72
+ pseudonymize = rawPseudonymize.map((entry) => validatePseudonymizeEntry(name, entry));
73
+
74
+ const maskedFields = new Set(mask ?? []);
75
+ for (const { field } of pseudonymize) {
76
+ if (maskedFields.has(field)) {
77
+ throw new Error(`entities.${name}: "${field}" cannot be listed in both "mask" and "pseudonymize"`);
78
+ }
79
+ }
80
+ }
81
+
82
+ return { mask, maxRows, allowTools, pseudonymize };
83
+ }
84
+
85
+ /**
86
+ * Validates an already-parsed `"cap-mcp-guard"` config object (the value of
87
+ * that key in package.json) into a PolicyDefinition the source-agnostic
88
+ * shape the policy engine consumes. Pure: no filesystem access.
89
+ *
90
+ * @param {object} raw the parsed `"cap-mcp-guard"` value
91
+ * @param {object} [opts]
92
+ * @param {string} [opts.source] label used in error messages (defaults to
93
+ * the conventional file name; loadConfig() passes the real path instead)
94
+ * @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined) }}
95
+ */
96
+ function parseConfig(raw, opts = {}) {
97
+ const { source = 'package.json' } = opts;
98
+
99
+ if (raw === undefined || raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
100
+ throw new Error(`${source}: "${CONFIG_KEY}" must be a mapping with a "mode" key`);
101
+ }
102
+
103
+ if (!VALID_MODES.includes(raw.mode)) {
104
+ throw new Error(`"mode" must be one of ${VALID_MODES.join(', ')} (got ${JSON.stringify(raw.mode)})`);
105
+ }
106
+
107
+ const rawEntities = raw.entities ?? {};
108
+ if (typeof rawEntities !== 'object' || Array.isArray(rawEntities)) {
109
+ throw new Error(`"entities" must be a mapping of entity name to config, got ${typeOf(rawEntities)}`);
110
+ }
111
+
112
+ const entities = {};
113
+ for (const [name, entityRaw] of Object.entries(rawEntities)) {
114
+ entities[name] = validateEntityConfig(name, entityRaw);
115
+ }
116
+
117
+ if (raw.services !== undefined && !Array.isArray(raw.services)) {
118
+ throw new Error(`"services" must be an array, got ${typeOf(raw.services)}`);
119
+ }
120
+
121
+ if (raw.users !== undefined && !Array.isArray(raw.users)) {
122
+ throw new Error(`"users" must be an array, got ${typeOf(raw.users)}`);
123
+ }
124
+
125
+ return { mode: raw.mode, entities, services: raw.services, users: raw.users };
126
+ }
127
+
128
+ /**
129
+ * Reads the host project's package.json and validates its `"cap-mcp-guard"`
130
+ * key via parseConfig(). Both "no package.json at this path" and "package.json
131
+ * exists but has no cap-mcp-guard key" are reported under the same
132
+ * NOT_CONFIGURED_PREFIX, so callers can treat either as "not configured yet"
133
+ * with a single startsWith() check and fall back to pass-through mode.
134
+ *
135
+ * @param {string} packageJsonPath
136
+ * @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined) }}
137
+ */
138
+ function loadConfig(packageJsonPath) {
139
+ let content;
140
+ try {
141
+ content = fs.readFileSync(packageJsonPath, 'utf8');
142
+ } catch (err) {
143
+ if (err.code === 'ENOENT') {
144
+ throw new Error(`${NOT_CONFIGURED_PREFIX}: no package.json found at ${packageJsonPath}`);
145
+ }
146
+ throw err;
147
+ }
148
+
149
+ let pkg;
150
+ try {
151
+ pkg = JSON.parse(content);
152
+ } catch (err) {
153
+ throw new Error(`Failed to parse ${packageJsonPath}: ${err.message}`);
154
+ }
155
+
156
+ const raw = pkg[CONFIG_KEY];
157
+ if (raw === undefined) {
158
+ throw new Error(`${NOT_CONFIGURED_PREFIX}: "${CONFIG_KEY}" key not found in ${packageJsonPath}`);
159
+ }
160
+
161
+ return parseConfig(raw, { source: packageJsonPath });
162
+ }
163
+
164
+ module.exports = { loadConfig, parseConfig, NOT_CONFIGURED_PREFIX };
@@ -1,5 +1,19 @@
1
1
  'use strict';
2
2
 
3
+ function passThroughDecision(context, policyDefinition) {
4
+ return {
5
+ mode: policyDefinition.mode,
6
+ allowed: true,
7
+ reason: null,
8
+ fieldsToMask: [],
9
+ fieldsToPseudonymize: [],
10
+ rowLimitExceeded: false,
11
+ maxRows: null,
12
+ entity: context.entity,
13
+ timestamp: context.timestamp
14
+ };
15
+ }
16
+
3
17
  /**
4
18
  * Evaluates a request Context (lib/core/context.js) against a
5
19
  * PolicyDefinition (lib/policy/config.js) and produces a Decision — what
@@ -14,22 +28,22 @@
14
28
  * @returns {object} Decision
15
29
  */
16
30
  function evaluate(context, policyDefinition) {
31
+ // policyDefinition.users, when set, scopes enforcement to just those identities
32
+ // (context.user, populated from CAP's own verified req.user.id) — e.g. a technical
33
+ // user the MCP runtime authenticates as, distinct from a human UI user hitting the
34
+ // same service. A request from anyone not in the list is fully allowed/unmasked,
35
+ // as if this entity had no policy at all.
36
+ if (Array.isArray(policyDefinition.users) && !policyDefinition.users.includes(context.user)) {
37
+ return passThroughDecision(context, policyDefinition);
38
+ }
39
+
17
40
  const entityConfig = policyDefinition.entities[context.entity];
18
41
 
19
42
  if (!entityConfig) {
20
- return {
21
- mode: policyDefinition.mode,
22
- allowed: true,
23
- reason: null,
24
- fieldsToMask: [],
25
- rowLimitExceeded: false,
26
- maxRows: null,
27
- entity: context.entity,
28
- timestamp: context.timestamp
29
- };
43
+ return passThroughDecision(context, policyDefinition);
30
44
  }
31
45
 
32
- const { allowTools, maxRows, mask } = entityConfig;
46
+ const { allowTools, maxRows, mask, pseudonymize } = entityConfig;
33
47
 
34
48
  let allowed = true;
35
49
  let reason = null;
@@ -46,6 +60,7 @@ function evaluate(context, policyDefinition) {
46
60
  allowed,
47
61
  reason,
48
62
  fieldsToMask: mask ?? [],
63
+ fieldsToPseudonymize: pseudonymize ?? [],
49
64
  rowLimitExceeded,
50
65
  maxRows: typeof maxRows === 'number' ? maxRows : null,
51
66
  entity: context.entity,
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ const PSEUDONYM_TYPES = ['opaque', 'iban'];
6
+
7
+ const IBAN_SHAPE = /^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/;
8
+
9
+ function hmacHex(value, secret) {
10
+ return crypto.createHmac('sha256', secret).update(String(value)).digest('hex');
11
+ }
12
+
13
+ /**
14
+ * Generic fallback: a deterministic, opaque token — same field+value+secret always
15
+ * produces the same token, but the token carries no information about the real value
16
+ * and doesn't resemble any particular data format.
17
+ */
18
+ function generateOpaquePseudonym(field, value, secret) {
19
+ const hex = hmacHex(`${field}:${value}`, secret);
20
+ return `${field}-${hex.slice(0, 12)}`;
21
+ }
22
+
23
+ /**
24
+ * Computes the 2-digit ISO 7064 MOD 97-10 check digits for an IBAN whose check-digit
25
+ * positions (chars 3-4) are currently '00'. Standard algorithm: move the first 4 chars
26
+ * to the end, map letters to numbers (A=10 .. Z=35), reduce mod 97 as a big integer via
27
+ * BigInt, check digits = 98 - remainder.
28
+ */
29
+ function computeIbanCheckDigits(ibanWithZeroedCheckDigits) {
30
+ const rearranged = ibanWithZeroedCheckDigits.slice(4) + ibanWithZeroedCheckDigits.slice(0, 4);
31
+ const numeric = rearranged.replace(/[A-Z]/g, (letter) => String(letter.charCodeAt(0) - 55));
32
+ const remainder = BigInt(numeric) % 97n;
33
+ return String(98n - remainder).padStart(2, '0');
34
+ }
35
+
36
+ /**
37
+ * Deterministic, format-preserving fake IBAN: same country code and total length as the
38
+ * real value, digits derived from HMAC(value, secret), and a real, valid ISO 7064 MOD
39
+ * 97-10 check digit pair — so it passes standard IBAN checksum validation even though
40
+ * it's entirely fake. Falls back to generateOpaquePseudonym() for anything that doesn't
41
+ * look like an IBAN (defensive — real-world field content can't be validated ahead of
42
+ * time the way config shape can).
43
+ */
44
+ function generateIbanPseudonym(value, secret) {
45
+ const normalized = String(value).replace(/\s+/g, '').toUpperCase();
46
+
47
+ if (!IBAN_SHAPE.test(normalized)) {
48
+ return generateOpaquePseudonym('IBAN', value, secret);
49
+ }
50
+
51
+ const countryCode = normalized.slice(0, 2);
52
+ const bbanLength = normalized.length - 4;
53
+
54
+ const hex = hmacHex(normalized, secret);
55
+ let digits = '';
56
+ for (let i = 0; digits.length < bbanLength; i++) {
57
+ const byte = parseInt(hex[i % hex.length], 16);
58
+ digits += String(byte % 10);
59
+ }
60
+ digits = digits.slice(0, bbanLength);
61
+
62
+ const withZeroedCheckDigits = `${countryCode}00${digits}`;
63
+ const checkDigits = computeIbanCheckDigits(withZeroedCheckDigits);
64
+
65
+ return `${countryCode}${checkDigits}${digits}`;
66
+ }
67
+
68
+ const GENERATORS = {
69
+ opaque: generateOpaquePseudonym,
70
+ iban: (field, value, secret) => generateIbanPseudonym(value, secret)
71
+ };
72
+
73
+ /**
74
+ * @param {string} field the field name being pseudonymized
75
+ * @param {*} value the real value
76
+ * @param {string} type one of PSEUDONYM_TYPES (validated by lib/policy/config.js)
77
+ * @param {string} secret never embedded in the output; without it, the pseudonym can't
78
+ * be reproduced or (for the opaque case) traced back to a specific value
79
+ * @returns {string}
80
+ */
81
+ function generatePseudonym(field, value, type, secret) {
82
+ const generator = GENERATORS[type];
83
+ if (!generator) {
84
+ throw new Error(`Unknown pseudonym type "${type}"`);
85
+ }
86
+ return generator(field, value, secret);
87
+ }
88
+
89
+ module.exports = { generatePseudonym, PSEUDONYM_TYPES };
package/package.json CHANGED
@@ -1,54 +1,56 @@
1
- {
2
- "name": "cap-mcp-guard",
3
- "version": "0.2.0",
4
- "description": "The trust layer for AI agents accessing SAP CAP business data — request interception, policy enforcement, field masking, and OpenTelemetry-native observability for MCP-enabled applications.",
5
- "main": "lib/index.js",
6
- "author": "Furkan Aksoy <furkanaksy838@gmail.com>",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/furkanaksy838/mcp.git"
10
- },
11
- "homepage": "https://github.com/furkanaksy838/mcp#readme",
12
- "bugs": {
13
- "url": "https://github.com/furkanaksy838/mcp/issues"
14
- },
15
- "files": [
16
- "lib",
17
- "cds-plugin.js"
18
- ],
19
- "scripts": {
20
- "test": "jest"
21
- },
22
- "keywords": [
23
- "cap",
24
- "sap",
25
- "odata",
26
- "mcp",
27
- "ai-agent",
28
- "policy",
29
- "opentelemetry"
30
- ],
31
- "license": "MIT",
32
- "engines": {
33
- "node": ">=20"
34
- },
35
- "peerDependencies": {
36
- "@sap/cds": ">=7"
37
- },
38
- "devDependencies": {
39
- "@opentelemetry/core": "^2.9.0",
40
- "@opentelemetry/sdk-trace-base": "^2.9.0",
41
- "jest": "^30.4.2"
42
- },
43
- "jest": {
44
- "testEnvironment": "node",
45
- "testPathIgnorePatterns": [
46
- "/node_modules/",
47
- "/examples/"
48
- ]
49
- },
50
- "dependencies": {
51
- "@opentelemetry/api": "^1.9.1",
52
- "js-yaml": "^5.2.1"
53
- }
54
- }
1
+ {
2
+ "name": "cap-mcp-guard",
3
+ "version": "0.3.0",
4
+ "description": "A CDS plugin — the trust layer for AI agents accessing SAP CAP business data — request interception, policy enforcement, field masking/pseudonymization, and OpenTelemetry-native observability for MCP-enabled applications.",
5
+ "main": "lib/index.js",
6
+ "author": "Furkan Aksoy <furkanaksy838@gmail.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/furkanaksy838/mcp.git"
10
+ },
11
+ "homepage": "https://github.com/furkanaksy838/mcp#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/furkanaksy838/mcp/issues"
14
+ },
15
+ "files": [
16
+ "lib",
17
+ "cds-plugin.js"
18
+ ],
19
+ "scripts": {
20
+ "test": "jest"
21
+ },
22
+ "keywords": [
23
+ "CAP",
24
+ "CDS",
25
+ "cds-plugin",
26
+ "cap",
27
+ "sap",
28
+ "odata",
29
+ "mcp",
30
+ "ai-agent",
31
+ "policy",
32
+ "opentelemetry"
33
+ ],
34
+ "license": "MIT",
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "peerDependencies": {
39
+ "@sap/cds": ">=7"
40
+ },
41
+ "devDependencies": {
42
+ "@opentelemetry/core": "^2.9.0",
43
+ "@opentelemetry/sdk-trace-base": "^2.9.0",
44
+ "jest": "^30.4.2"
45
+ },
46
+ "jest": {
47
+ "testEnvironment": "node",
48
+ "testPathIgnorePatterns": [
49
+ "/node_modules/",
50
+ "/examples/"
51
+ ]
52
+ },
53
+ "dependencies": {
54
+ "@opentelemetry/api": "^1.9.1"
55
+ }
56
+ }