cap-mcp-guard 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -96,6 +96,44 @@ entities:
96
96
 
97
97
  All three run independently and can each be disabled per-call (`audit: false`, `otel: false`) if you're wiring `registerCapMcpGuard` yourself instead of relying on auto-discovery.
98
98
 
99
+ ## OData
100
+
101
+ `registerCapMcpGuard` hooks CAP's own `before`/`after` service events, which only exists inside a Node CAP process. A lot of business data reaches an MCP agent over OData without ever passing through those hooks — a plain OData V2/V4 service, a CAP Java service, or an on-prem SAP Gateway/S/4HANA system fronted by a proxy route. `odataMcpGuard` runs the same Context → Decision → mask/audit/OTel pipeline against raw OData HTTP traffic instead, using one `cap-mcp-guard.yaml`:
102
+
103
+ ```js
104
+ const express = require('express');
105
+ const { odataMcpGuard } = require('cap-mcp-guard');
106
+
107
+ const app = express();
108
+ app.use('/odata', odataMcpGuard(), proxyToYourODataBackend);
109
+ ```
110
+
111
+ - Mount it in front of any route that responds via `res.json(body)` — your own OData handler, or a reverse-proxy route rewriting a remote OData service's response.
112
+ - Entity names are resolved from the URL path (`/odata/v4/browse/Books(201)` → `Books`), so `cap-mcp-guard.yaml` keys entities by the same name whichever adapter is in front of them. HTTP methods map onto the same operation vocabulary CAP requests use (`GET`→`READ`, `POST`→`CREATE`, `PUT`/`PATCH`→`UPDATE`, `DELETE`→`DELETE`), so `allowTools` rules are portable too.
113
+ - **`$expand` is masked too.** `?$expand=author` on a `Books` read pulls the related `Author` row inline — without any extra config, its fields are masked under an `author` entity entry; nested `$expand=genre($expand=parent)` is masked at every level. One audit/OTel entry is produced per entity in the response (root + each expanded nav), not just one per HTTP request.
114
+ - **Pass real `$metadata` for exact entity resolution.** By default, entity names are guessed from the URL/nav-property text itself (`Books(201)/author` → `author`, not the real target entity). Pass `metadataXml` (an already-fetched `$metadata` EDMX/CSDL document — this module never fetches it itself) or a pre-parsed `edm` (see `lib/core/edm.js`), and both deep paths and `$expand` resolve through the real navigation properties instead (`Books(201)/author` → `Authors`):
115
+
116
+ ```js
117
+ const metadataXml = fs.readFileSync('./service.edmx', 'utf8'); // or fetch it once at startup
118
+ app.use('/odata', odataMcpGuard({ metadataXml }), proxyToYourODataBackend);
119
+ ```
120
+
121
+ - **`$batch` is decoded too, both wire formats** — each sub-request inside a batch is masked/audited/traced individually, exactly like a standalone request:
122
+ - **OData V4 JSON batch** (`{ requests: [...] }` request / `{ responses: [...] }` response) needs `req.body` to already be the parsed request JSON — mount a JSON body-parser (`express.json()`) before this middleware.
123
+ - **Classic OData V2 `multipart/mixed` batch** (including changesets — grouped write operations) needs `req.body` to already be the *raw* request body, and goes out via `res.send(rawBody)` rather than `res.json()`:
124
+
125
+ ```js
126
+ const express = require('express');
127
+ app.use(
128
+ '/sap/opu/odata',
129
+ express.raw({ type: 'multipart/mixed' }), // populates req.body for $batch requests
130
+ odataMcpGuard(),
131
+ proxyToYourODataBackend
132
+ );
133
+ ```
134
+
135
+ If a batch can't be decoded either way (missing/wrong `req.body`, no boundary, malformed MIME), it still produces one Context/Decision/audit entry (with an undefined entity) so it isn't silently unaccounted for — it's just unmasked.
136
+
99
137
  ## Try it
100
138
 
101
139
  A full working example lives in [`examples/bookshop`](examples/bookshop) — SAP's own CAP getting-started sample, with `cap-mcp-guard` wired in and a `cap-mcp-guard.yaml` masking real fields on `CatalogService.Books`.
@@ -113,7 +151,6 @@ npm start # boots a real server at localhost:4004 — flip cap-mcp-guard.yaml
113
151
  - `@mcp.policy`-style CDS annotations as an alternative to `cap-mcp-guard.yaml`
114
152
  - Approval workflows (human-in-the-loop for sensitive operations)
115
153
  - Rate limiting and a dashboard UI
116
- - Adapters for frameworks other than CAP
117
154
  - Actually blocking a request when `allowTools`/`maxRows` is violated (today those are computed and reported, not enforced)
118
155
 
119
156
  ## Development
@@ -0,0 +1,366 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+
5
+ const { buildContext } = require('../core/context');
6
+ const { resolveEntityPath, resolveOperation, isBatchRequest, collectMaskableGroups } = require('../core/odata');
7
+ const { parseMetadata, parseExpandOption } = require('../core/edm');
8
+ const { extractBoundary, parseBatchRequests, parseBatchResponses, serializeBatchResponses } = require('../core/odata-batch');
9
+ const { evaluate } = require('../policy/evaluator');
10
+ const { maskFields } = require('../policy/masking');
11
+ const { loadConfig } = require('../policy/config');
12
+ const { logAudit } = require('../audit/log');
13
+ const { exportSpan } = require('../otel/exporter');
14
+
15
+ const CONFIG_FILE_NAME = 'cap-mcp-guard.yaml';
16
+ const NOT_FOUND_PREFIX = `${CONFIG_FILE_NAME} not found`;
17
+
18
+ /**
19
+ * Loads cap-mcp-guard.yaml from `cwd` — same convention and same file
20
+ * lib/adapters/cap.js reads, so one policy file covers both a CAP app's
21
+ * own service layer and any OData traffic this middleware fronts. A
22
+ * missing config is a valid "not configured yet" state (pass-through); a
23
+ * config that exists but fails to parse propagates (see cap.js for the
24
+ * same reasoning).
25
+ *
26
+ * @param {string} [cwd] defaults to process.cwd()
27
+ */
28
+ function resolvePolicyDefinition(cwd) {
29
+ const configPath = path.join(cwd || process.cwd(), CONFIG_FILE_NAME);
30
+
31
+ try {
32
+ return loadConfig(configPath);
33
+ } catch (err) {
34
+ if (err.message.startsWith(NOT_FOUND_PREFIX)) {
35
+ console.warn('[cap-mcp-guard] no config found, running in pass-through mode (no policies enforced)');
36
+ return { mode: 'observe', entities: {} };
37
+ }
38
+ throw err;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * OData has no MCP-runtime `_meta` bag to read (that's a CAP/CDS-side
44
+ * convention) — the wire-level equivalent is plain HTTP headers, which any
45
+ * proxying MCP runtime can set alongside a request it forwards.
46
+ */
47
+ function extractTraceContext(req) {
48
+ const headers = (req && req.headers) || {};
49
+ return { traceparent: headers.traceparent, tracestate: headers.tracestate };
50
+ }
51
+
52
+ function extractAgentInfo(req) {
53
+ const headers = (req && req.headers) || {};
54
+ return {
55
+ agentId: headers['x-gen-ai-agent-id'],
56
+ agentName: headers['x-gen-ai-agent-name'],
57
+ model: headers['x-gen-ai-request-model']
58
+ };
59
+ }
60
+
61
+ function getExpandTree(url) {
62
+ const query = (url || '').split('?')[1];
63
+ if (!query) return {};
64
+
65
+ const expand = new URLSearchParams(query).get('$expand');
66
+ return parseExpandOption(expand);
67
+ }
68
+
69
+ /**
70
+ * Same in-place-mutation strategy as lib/core/interceptor.js's applyMask:
71
+ * maskFields() itself stays pure, this is the one place that turns its
72
+ * (immutable) output into a real effect on the rows already referenced
73
+ * inside the response body (see extractResultRows / collectMaskableGroups).
74
+ */
75
+ function applyMask(rows, fieldsToMask) {
76
+ const masked = maskFields(rows, fieldsToMask);
77
+
78
+ rows.forEach((row, i) => {
79
+ if (!row || typeof row !== 'object') return;
80
+ for (const field of fieldsToMask) {
81
+ if (Object.prototype.hasOwnProperty.call(row, field)) {
82
+ row[field] = masked[i][field];
83
+ }
84
+ }
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Builds an Express-compatible middleware `(req, res, next)` that runs the
90
+ * same Context → Decision → mask/audit/OTel pipeline as
91
+ * lib/adapters/cap.js, driven off raw OData HTTP request/response instead
92
+ * of CAP's before()/after() hooks. Mount it in front of ANY OData V2/V4
93
+ * traffic — a hand-rolled OData service, a proxy route to SAP Gateway or
94
+ * S/4HANA, a CAP Java service, or this same CAP Node app's own OData
95
+ * endpoint — regardless of what produced it, since it only ever looks at
96
+ * the HTTP request/response, never at a CDS model.
97
+ *
98
+ * Entity resolution is URL-path-based (lib/core/odata.js's
99
+ * resolveEntityPath()). Without `metadataXml`/`edm`, it's a last-path-
100
+ * segment heuristic, same as before; pass either one to resolve deep
101
+ * paths (`Books(201)/author`) and `$expand`'ed nested entities
102
+ * (`?$expand=author`) against the real EDM, so masking reaches nested
103
+ * data too, not just the top-level rows.
104
+ *
105
+ * `$batch` requests are decoded on BOTH wire formats, each sub-request
106
+ * masked/audited/traced individually exactly like a standalone request:
107
+ * - OData V4 JSON batch (`{ requests: [...] }` request body /
108
+ * `{ responses: [...] }` response body) — requires `req.body` to
109
+ * already be the parsed JSON request (i.e. a JSON body-parser like
110
+ * `express.json()` ran before this middleware).
111
+ * - Classic OData V2 `multipart/mixed` batch (see lib/core/odata-batch.js)
112
+ * — requires `req.body` to already be the raw request Buffer/string
113
+ * (e.g. `express.raw({ type: 'multipart/mixed' })` ran before this
114
+ * middleware) and goes out via `res.send(rawBody)`, not `res.json()`.
115
+ * If a batch can't be decoded either way (wrong/missing `req.body`,
116
+ * missing boundary, malformed MIME), it still produces one Context/
117
+ * Decision/audit entry (with an undefined entity) so it isn't silently
118
+ * unaccounted for — it's just unmasked.
119
+ *
120
+ * Response bodies are only inspected when the downstream handler calls
121
+ * `res.json(body)` (always) or, for a batch request specifically,
122
+ * `res.send(rawBody)` too — this middleware wraps those methods for the
123
+ * life of the request. If `res.json` isn't a function (a bare Node `http`
124
+ * response, or a framework that never provides it), the middleware warns
125
+ * once and passes every request through unguarded rather than throwing.
126
+ *
127
+ * @param {object} [options]
128
+ * @param {object} [options.policyDefinition] see lib/policy/config.js —
129
+ * when omitted, loaded from cap-mcp-guard.yaml in options.cwd
130
+ * @param {string} [options.cwd] directory cap-mcp-guard.yaml is resolved
131
+ * from when policyDefinition isn't supplied — defaults to process.cwd()
132
+ * @param {string} [options.metadataXml] a `$metadata` EDMX/CSDL document
133
+ * (already fetched — this module never makes network calls itself),
134
+ * parsed once via lib/core/edm.js's parseMetadata()
135
+ * @param {object} [options.edm] an already-parsed EDM (see
136
+ * lib/core/edm.js parseMetadata()) — takes precedence over metadataXml
137
+ * @param {(context: object, req: object) => void} [options.onContext]
138
+ * called with the built context after each request completes
139
+ * @param {object|false} [options.audit] forwarded to logAudit()'s write
140
+ * options (`{ stdout, filePath }`); pass `false` to disable audit
141
+ * logging entirely
142
+ * @param {object|false} [options.otel] forwarded to exportSpan()'s options
143
+ * (`{ tracer }`); pass `false` to skip OTel span export entirely
144
+ * @param {(decision: object, context: object, req: object) => void} [options.onDecision]
145
+ * called in addition to the built-in audit log and OTel export, not
146
+ * instead of them
147
+ * @param {() => string} [options.now] injectable clock, forwarded to buildContext
148
+ * @returns {(req: object, res: object, next: Function) => void}
149
+ */
150
+ function odataMcpGuard(options = {}) {
151
+ const policyDefinition = options.policyDefinition || resolvePolicyDefinition(options.cwd);
152
+ const { onContext, onDecision: userOnDecision, audit, otel, now } = options;
153
+ const edm = options.edm || (options.metadataXml && parseMetadata(options.metadataXml));
154
+ const clockDeps = now ? { now } : {};
155
+
156
+ let warnedNoJson = false;
157
+
158
+ /**
159
+ * The full Context → Decision → mask/audit/OTel pipeline for one logical
160
+ * request — either the request as a whole, or (for a decoded $batch)
161
+ * one of its sub-requests. Mutates `rows` in place when enforcing.
162
+ */
163
+ function runPipeline({ req, entity, operation, rows, rowCount, durationMs }) {
164
+ const context = buildContext(
165
+ {
166
+ ...extractAgentInfo(req),
167
+ ...extractTraceContext(req),
168
+ entity,
169
+ operation,
170
+ tenant: req.headers && req.headers['sap-tenantid'],
171
+ user: req.user && (req.user.id || req.user.name),
172
+ session: req.headers && req.headers['x-request-id'],
173
+ rowCount,
174
+ durationMs
175
+ },
176
+ clockDeps
177
+ );
178
+
179
+ if (typeof onContext === 'function') onContext(context, req);
180
+ if (!policyDefinition) return;
181
+
182
+ const decision = evaluate(context, policyDefinition);
183
+
184
+ if (decision.mode === 'enforce' && decision.fieldsToMask.length > 0) {
185
+ applyMask(rows, decision.fieldsToMask);
186
+ }
187
+
188
+ if (audit !== false) logAudit(context, decision, audit);
189
+ if (otel !== false) exportSpan(context, decision, otel);
190
+ if (typeof userOnDecision === 'function') userOnDecision(decision, context, req);
191
+ }
192
+
193
+ /** Runs one sub-request's worth of the pipeline, grouping $expand-ed nested entities too. */
194
+ function runSubRequestPipeline(req, url, method, responseBody, durationMs) {
195
+ const entity = resolveEntityPath(url, edm);
196
+ const operation = resolveOperation(method);
197
+ const expandTree = getExpandTree(url);
198
+ const groups = collectMaskableGroups(responseBody, entity, expandTree, edm);
199
+
200
+ if (groups.length === 0) {
201
+ runPipeline({ req, entity, operation, rows: [], rowCount: responseBody === undefined ? undefined : 0, durationMs });
202
+ return;
203
+ }
204
+
205
+ for (const group of groups) {
206
+ runPipeline({ req, entity: group.entity, operation, rows: group.rows, rowCount: group.rows.length, durationMs });
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Decodes and guards an OData V4 JSON batch: `req.body.requests` (each
212
+ * `{ id, method, url, ... }`) matched by `id` to `body.responses` (each
213
+ * `{ id, status, body, ... }`), so every sub-request is resolved,
214
+ * evaluated, and masked exactly like a standalone request would be.
215
+ * Returns false (nothing decoded — caller falls back to legacy
216
+ * pass-through) when either side isn't shaped like a V4 JSON batch.
217
+ */
218
+ function runJsonBatchPipeline(req, body, durationMs) {
219
+ const requests = req.body && Array.isArray(req.body.requests) ? req.body.requests : undefined;
220
+ const responses = body && Array.isArray(body.responses) ? body.responses : undefined;
221
+ if (!requests || !responses) return false;
222
+
223
+ const requestById = new Map(requests.map((r) => [r.id, r]));
224
+
225
+ for (const responseEntry of responses) {
226
+ const subRequest = requestById.get(responseEntry.id);
227
+ if (!subRequest || !subRequest.url) continue;
228
+
229
+ runSubRequestPipeline(req, subRequest.url, subRequest.method, responseEntry.body, durationMs);
230
+ }
231
+
232
+ return true;
233
+ }
234
+
235
+ /**
236
+ * Zips a parsed batch request tree to its response tree by position —
237
+ * each top-level (or changeset-nested) request corresponds to exactly
238
+ * one response in the same slot, per OData $batch semantics. Mismatched
239
+ * shapes (a 'single' lined up against a 'changeset', or a length
240
+ * mismatch) are skipped rather than crashing the response.
241
+ */
242
+ function zipClassicBatch(req, requestItems, responseItems, durationMs) {
243
+ const len = Math.min(requestItems.length, responseItems.length);
244
+
245
+ for (let i = 0; i < len; i++) {
246
+ const reqItem = requestItems[i];
247
+ const resItem = responseItems[i];
248
+
249
+ if (reqItem.kind === 'changeset' && resItem.kind === 'changeset') {
250
+ zipClassicBatch(req, reqItem.parts, resItem.parts, durationMs);
251
+ continue;
252
+ }
253
+ if (reqItem.kind !== 'single' || resItem.kind !== 'single') continue;
254
+
255
+ let parsedBody;
256
+ try {
257
+ parsedBody = resItem.body ? JSON.parse(resItem.body) : undefined;
258
+ } catch {
259
+ parsedBody = undefined;
260
+ }
261
+
262
+ runSubRequestPipeline(req, reqItem.url, reqItem.method, parsedBody, durationMs);
263
+ if (parsedBody !== undefined) resItem.body = JSON.stringify(parsedBody);
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Decodes and guards a classic OData V2 `multipart/mixed` $batch: parses
269
+ * `req.body` (the raw request body — a JSON body-parser won't populate
270
+ * this for a multipart content-type, so the host needs something like
271
+ * `express.raw({ type: 'multipart/mixed' })` mounted first) and the raw
272
+ * response body about to be sent, zips requests to responses by
273
+ * position, masks each response's JSON in place, and returns the
274
+ * re-serialized response body. Returns false (caller falls back to
275
+ * legacy pass-through) when either side can't be decoded as multipart —
276
+ * missing boundary, `req.body` not pre-buffered, or malformed MIME.
277
+ */
278
+ function runClassicBatchPipeline(req, res, rawResponseBody, durationMs) {
279
+ const reqBoundary = extractBoundary(req.headers && req.headers['content-type']);
280
+ const resBoundary = extractBoundary(typeof res.getHeader === 'function' ? res.getHeader('content-type') : undefined);
281
+
282
+ const rawRequestBody = req.body;
283
+ const requestIsBufferLike = typeof rawRequestBody === 'string' || Buffer.isBuffer(rawRequestBody);
284
+ const responseIsBufferLike = typeof rawResponseBody === 'string' || Buffer.isBuffer(rawResponseBody);
285
+
286
+ if (!reqBoundary || !resBoundary || !requestIsBufferLike || !responseIsBufferLike) return false;
287
+
288
+ let requestItems;
289
+ let responseItems;
290
+ try {
291
+ requestItems = parseBatchRequests(rawRequestBody, reqBoundary);
292
+ responseItems = parseBatchResponses(rawResponseBody, resBoundary);
293
+ } catch {
294
+ return false; // malformed multipart — don't crash the response, fall back to legacy pass-through
295
+ }
296
+
297
+ zipClassicBatch(req, requestItems, responseItems, durationMs);
298
+ return serializeBatchResponses(responseItems, resBoundary);
299
+ }
300
+
301
+ return function odataMcpGuardMiddleware(req, res, next) {
302
+ if (typeof res.json !== 'function') {
303
+ if (!warnedNoJson) {
304
+ console.warn('[cap-mcp-guard] res.json is not available; OData responses will pass through unguarded');
305
+ warnedNoJson = true;
306
+ }
307
+ return next();
308
+ }
309
+
310
+ const startedAt = process.hrtime.bigint();
311
+ const operation = resolveOperation(req.method);
312
+ const batch = isBatchRequest(req.url);
313
+ const originalJson = res.json.bind(res);
314
+
315
+ // Shared between the res.json and res.send patches below: Express's
316
+ // real res.json() (captured as originalJson) internally calls
317
+ // this.send(...) — since that resolves to OUR patched res.send when
318
+ // `batch` is true, this flag stops the batch already handled by
319
+ // guardedJson from being processed a second time by guardedSend.
320
+ let handled = false;
321
+
322
+ res.json = function guardedJson(body) {
323
+ if (handled) return originalJson(body);
324
+ handled = true;
325
+
326
+ const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
327
+
328
+ if (batch) {
329
+ if (!runJsonBatchPipeline(req, body, durationMs)) {
330
+ // classic multipart/mixed batch that didn't go through res.send,
331
+ // or req.body wasn't pre-parsed: still audited, nothing masked
332
+ runPipeline({ req, entity: undefined, operation, rows: [], rowCount: undefined, durationMs });
333
+ }
334
+ return originalJson(body);
335
+ }
336
+
337
+ runSubRequestPipeline(req, req.url, req.method, body, durationMs);
338
+ return originalJson(body);
339
+ };
340
+
341
+ // Classic multipart/mixed $batch responses go out via res.send(rawBody),
342
+ // never res.json() — only patch res.send for batch requests, so the
343
+ // (already tested) non-batch path never touches it.
344
+ if (batch && typeof res.send === 'function') {
345
+ const originalSend = res.send.bind(res);
346
+
347
+ res.send = function guardedSend(bodyChunk) {
348
+ if (handled) return originalSend(bodyChunk);
349
+ handled = true;
350
+
351
+ const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
352
+ const newBody = runClassicBatchPipeline(req, res, bodyChunk, durationMs);
353
+
354
+ if (newBody !== false) return originalSend(newBody);
355
+
356
+ // couldn't decode as classic multipart batch either: still audited
357
+ runPipeline({ req, entity: undefined, operation, rows: [], rowCount: undefined, durationMs });
358
+ return originalSend(bodyChunk);
359
+ };
360
+ }
361
+
362
+ next();
363
+ };
364
+ }
365
+
366
+ module.exports = { odataMcpGuard };
@@ -0,0 +1,241 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * A hand-rolled, regex-based reader for OData `$metadata` documents (EDMX/
5
+ * CSDL XML, V2 or V4) — not a general-purpose XML/EDM implementation, just
6
+ * enough structure (entity sets, entity types, navigation properties) to
7
+ * resolve a URL path or a `$expand` nav-property name down to the entity
8
+ * name cap-mcp-guard.yaml keys entities by. Good enough for the
9
+ * well-formed $metadata documents SAP/CAP services actually emit.
10
+ *
11
+ * Pure — no I/O. Fetching `$metadata` over the network is the caller's
12
+ * responsibility (see lib/adapters/odata.js's `metadataXml`/`edm` options);
13
+ * keeping that out of this module means it stays synchronous and
14
+ * dependency-free.
15
+ */
16
+
17
+ function matchAll(pattern, text) {
18
+ const results = [];
19
+ const re = new RegExp(pattern, 'g');
20
+ let m;
21
+ while ((m = re.exec(text))) results.push(m);
22
+ return results;
23
+ }
24
+
25
+ function getAttr(tagAttrs, name) {
26
+ const m = new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`).exec(tagAttrs);
27
+ return m ? m[1] : undefined;
28
+ }
29
+
30
+ function unwrapCollection(type) {
31
+ const m = /^Collection\(([^)]+)\)$/.exec(type || '');
32
+ return m ? { target: m[1], isCollection: true } : { target: type, isCollection: false };
33
+ }
34
+
35
+ function unqualifiedName(fqName) {
36
+ if (!fqName) return fqName;
37
+ const idx = fqName.lastIndexOf('.');
38
+ return idx === -1 ? fqName : fqName.slice(idx + 1);
39
+ }
40
+
41
+ /**
42
+ * Parses an EDMX/CSDL XML string into a minimal EDM model.
43
+ *
44
+ * @param {string} xml
45
+ * @returns {{
46
+ * entityTypes: Record<string, { navigationProperties: Record<string, { target: string, isCollection?: boolean }> }>,
47
+ * entitySets: Record<string, string>,
48
+ * entityTypeToSet: Record<string, string>
49
+ * }}
50
+ */
51
+ function parseMetadata(xml) {
52
+ const entityTypes = {};
53
+ const entitySets = {};
54
+ const entityTypeToSet = {};
55
+
56
+ const schemaBlocks = matchAll('<Schema\\b([^>]*)>([\\s\\S]*?)</Schema>', xml);
57
+
58
+ for (const schemaMatch of schemaBlocks) {
59
+ const namespace = getAttr(schemaMatch[1], 'Namespace');
60
+ const schemaBody = schemaMatch[2];
61
+ if (!namespace) continue;
62
+
63
+ // V2 only: NavigationProperty indirects through Relationship/ToRole to
64
+ // an <Association>'s <End Role="..." Type="...">. V4 NavigationProperty
65
+ // carries its target Type directly, no association needed.
66
+ const associations = {};
67
+ for (const assocMatch of matchAll('<Association\\b[^>]*Name="([^"]*)"[^>]*>([\\s\\S]*?)</Association>', schemaBody)) {
68
+ const fqName = `${namespace}.${assocMatch[1]}`;
69
+ const roles = {};
70
+ for (const endMatch of matchAll('<End\\b([^>]*?)/?>', assocMatch[2])) {
71
+ const role = getAttr(endMatch[1], 'Role');
72
+ const type = getAttr(endMatch[1], 'Type');
73
+ if (role && type) roles[role] = type;
74
+ }
75
+ associations[fqName] = roles;
76
+ }
77
+
78
+ for (const entityTypeMatch of matchAll('<EntityType\\b([^>]*)>([\\s\\S]*?)</EntityType>', schemaBody)) {
79
+ const localName = getAttr(entityTypeMatch[1], 'Name');
80
+ if (!localName) continue;
81
+ const fqName = `${namespace}.${localName}`;
82
+ const body = entityTypeMatch[2];
83
+
84
+ const navigationProperties = {};
85
+ for (const navMatch of matchAll('<NavigationProperty\\b([^>]*?)/?>', body)) {
86
+ const attrs = navMatch[1];
87
+ const navName = getAttr(attrs, 'Name');
88
+ if (!navName) continue;
89
+
90
+ const typeAttr = getAttr(attrs, 'Type');
91
+ if (typeAttr) {
92
+ navigationProperties[navName] = unwrapCollection(typeAttr);
93
+ continue;
94
+ }
95
+
96
+ const relationship = getAttr(attrs, 'Relationship');
97
+ const toRole = getAttr(attrs, 'ToRole');
98
+ const target = relationship && toRole && associations[relationship] && associations[relationship][toRole];
99
+ if (target) {
100
+ navigationProperties[navName] = { target, isCollection: undefined };
101
+ }
102
+ }
103
+
104
+ entityTypes[fqName] = { navigationProperties };
105
+ }
106
+
107
+ for (const setMatch of matchAll('<EntitySet\\b([^>]*?)/?>', schemaBody)) {
108
+ const attrs = setMatch[1];
109
+ const setName = getAttr(attrs, 'Name');
110
+ const entityType = getAttr(attrs, 'EntityType');
111
+ if (!setName || !entityType) continue;
112
+
113
+ entitySets[setName] = entityType;
114
+ if (!entityTypeToSet[entityType]) entityTypeToSet[entityType] = setName;
115
+ }
116
+ }
117
+
118
+ return { entityTypes, entitySets, entityTypeToSet };
119
+ }
120
+
121
+ /** An entity set name when one maps to this type, else its bare local name. */
122
+ function toDisplayName(edm, fqTypeName) {
123
+ return (edm.entityTypeToSet && edm.entityTypeToSet[fqTypeName]) || unqualifiedName(fqTypeName);
124
+ }
125
+
126
+ /**
127
+ * Resolves already-split, already-`(key)`-stripped, $-segment-free URL path
128
+ * segments against a parsed EDM, walking navigation properties hop by hop
129
+ * from the first segment that matches a known entity set (segments before
130
+ * that — a service mount path like `odata/v4/browse` — are ignored, so
131
+ * this works regardless of where the service is mounted).
132
+ *
133
+ * Falls back to the last path segment (matching lib/core/odata.js's
134
+ * no-metadata resolveEntitySet() heuristic) when no segment matches a
135
+ * known entity set, and to the raw segment name at any hop whose nav
136
+ * property isn't described in the EDM — a `$metadata` document that
137
+ * doesn't fully describe every segment shouldn't break resolution for the
138
+ * segments it DOES know about.
139
+ *
140
+ * @param {object} edm see parseMetadata()
141
+ * @param {string[]} segments
142
+ * @returns {string|undefined}
143
+ */
144
+ function resolveEntityForPath(edm, segments) {
145
+ if (!segments.length) return undefined;
146
+
147
+ const startIndex = segments.findIndex((s) => Object.prototype.hasOwnProperty.call(edm.entitySets, s));
148
+ if (startIndex === -1) return segments[segments.length - 1];
149
+
150
+ let currentType = edm.entitySets[segments[startIndex]];
151
+ let displayName = toDisplayName(edm, currentType);
152
+
153
+ for (let i = startIndex + 1; i < segments.length; i++) {
154
+ const entityType = currentType && edm.entityTypes[currentType];
155
+ const nav = entityType && entityType.navigationProperties[segments[i]];
156
+
157
+ if (!nav) {
158
+ displayName = segments[i];
159
+ currentType = undefined;
160
+ continue;
161
+ }
162
+
163
+ currentType = nav.target;
164
+ displayName = toDisplayName(edm, currentType);
165
+ }
166
+
167
+ return displayName;
168
+ }
169
+
170
+ /**
171
+ * Resolves a `$expand`'ed navigation-property name to its target entity's
172
+ * policy-config-friendly name, given the (already-resolved) entity name of
173
+ * the row it hangs off of. Falls back to the raw nav-property name when
174
+ * the parent entity or the nav property itself isn't described in the EDM.
175
+ *
176
+ * @param {object} edm
177
+ * @param {string} parentEntityName as returned by resolveEntityForPath() or
178
+ * a previous resolveExpandTarget() call
179
+ * @param {string} navPropName
180
+ * @returns {string}
181
+ */
182
+ function resolveExpandTarget(edm, parentEntityName, navPropName) {
183
+ const parentType =
184
+ edm.entitySets[parentEntityName] ||
185
+ Object.keys(edm.entityTypes).find((fq) => toDisplayName(edm, fq) === parentEntityName);
186
+
187
+ const entityType = parentType && edm.entityTypes[parentType];
188
+ const nav = entityType && entityType.navigationProperties[navPropName];
189
+
190
+ return nav ? toDisplayName(edm, nav.target) : navPropName;
191
+ }
192
+
193
+ /**
194
+ * Parses an OData `$expand` query-option value into a tree of nav-property
195
+ * names to recurse into — e.g. `"author,genre($expand=parent)"` becomes
196
+ * `{ author: {}, genre: { parent: {} } }`. Only a nested `$expand=` option
197
+ * is honored inside a parenthesized nav property; other nested options
198
+ * (`$select`, `$filter`, ...) don't change which nested entities show up
199
+ * in the response body, so they're ignored.
200
+ *
201
+ * @param {string} expandParam raw (already URL-decoded) $expand value
202
+ * @returns {object} nested tree, `{}` for an empty/missing value
203
+ */
204
+ function parseExpandOption(expandParam) {
205
+ const tree = {};
206
+ if (!expandParam) return tree;
207
+
208
+ const parts = [];
209
+ let depth = 0;
210
+ let start = 0;
211
+ for (let i = 0; i < expandParam.length; i++) {
212
+ const ch = expandParam[i];
213
+ if (ch === '(') depth++;
214
+ else if (ch === ')') depth--;
215
+ else if (ch === ',' && depth === 0) {
216
+ parts.push(expandParam.slice(start, i));
217
+ start = i + 1;
218
+ }
219
+ }
220
+ parts.push(expandParam.slice(start));
221
+
222
+ for (const rawPart of parts) {
223
+ const part = rawPart.trim();
224
+ if (!part) continue;
225
+
226
+ const parenIdx = part.indexOf('(');
227
+ if (parenIdx === -1) {
228
+ tree[part] = tree[part] || {};
229
+ continue;
230
+ }
231
+
232
+ const navName = part.slice(0, parenIdx).trim();
233
+ const inner = part.slice(parenIdx + 1, part.lastIndexOf(')'));
234
+ const nestedExpandMatch = /\$expand=([^;]*)/.exec(inner);
235
+ tree[navName] = parseExpandOption(nestedExpandMatch ? nestedExpandMatch[1] : '');
236
+ }
237
+
238
+ return tree;
239
+ }
240
+
241
+ module.exports = { parseMetadata, resolveEntityForPath, resolveExpandTarget, parseExpandOption };
@@ -0,0 +1,161 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * A minimal reader/writer for the classic OData V2 `$batch` wire format:
5
+ * a `multipart/mixed` body whose parts are either a single embedded HTTP
6
+ * request/response (`Content-Type: application/http`) or a nested
7
+ * `multipart/mixed` "changeset" grouping several write operations
8
+ * atomically. Not a general-purpose MIME implementation — just enough
9
+ * structure to zip a batch's requests to its responses by position and
10
+ * mask each response's JSON body in place.
11
+ *
12
+ * Responses are re-serialized canonically (consistent CRLF line endings,
13
+ * consistent header ordering per part) rather than byte-for-byte
14
+ * preserved — that's syntactically equivalent multipart/mixed, which is
15
+ * all any compliant OData client relies on; it does not reproduce the
16
+ * exact original bytes.
17
+ *
18
+ * Pure — no I/O, no HTTP framework dependency.
19
+ */
20
+
21
+ function toText(value) {
22
+ return Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
23
+ }
24
+
25
+ /**
26
+ * @param {string} contentType a Content-Type header value
27
+ * @returns {string|undefined} the `boundary` parameter, unquoted
28
+ */
29
+ function extractBoundary(contentType) {
30
+ const m = /boundary=("?)([^;"]+)\1/i.exec(contentType || '');
31
+ return m ? m[2] : undefined;
32
+ }
33
+
34
+ /** Splits a multipart body into its raw part strings, boundary markers stripped. */
35
+ function splitMultipart(rawBody, boundary) {
36
+ const text = toText(rawBody);
37
+ const marker = `--${boundary}`;
38
+ const segments = text.split(marker);
39
+
40
+ const parts = [];
41
+ for (let i = 1; i < segments.length; i++) {
42
+ const segment = segments[i];
43
+ if (segment.startsWith('--')) break; // the closing "--boundary--" marker
44
+
45
+ const trimmed = segment.replace(/^\r?\n/, '').replace(/\r?\n$/, '');
46
+ if (trimmed.length) parts.push(trimmed);
47
+ }
48
+ return parts;
49
+ }
50
+
51
+ function splitHeadersAndBody(text) {
52
+ const m = /\r?\n\r?\n/.exec(text);
53
+ if (!m) return { headerBlock: text, body: '' };
54
+ return { headerBlock: text.slice(0, m.index), body: text.slice(m.index + m[0].length) };
55
+ }
56
+
57
+ function parseHeaderBlock(headerBlock) {
58
+ const headers = {};
59
+ for (const line of headerBlock.split(/\r?\n/)) {
60
+ const m = /^([^:]+):\s*(.*)$/.exec(line);
61
+ if (m) headers[m[1].trim().toLowerCase()] = m[2].trim();
62
+ }
63
+ return headers;
64
+ }
65
+
66
+ /** One embedded `application/http` part: a request-line/status-line, headers, and a body. */
67
+ function parseHttpEnvelope(text, { isResponse }) {
68
+ const { headerBlock, body } = splitHeadersAndBody(text);
69
+ const [firstLine, ...headerLines] = headerBlock.split(/\r?\n/);
70
+
71
+ if (isResponse) {
72
+ const m = /^HTTP\/[\d.]+\s+(\d+)\s*(.*)$/.exec((firstLine || '').trim());
73
+ return {
74
+ kind: 'single',
75
+ status: m ? Number(m[1]) : 200,
76
+ statusText: m ? m[2].trim() : 'OK',
77
+ headerLines: headerLines.filter((l) => l.trim().length > 0),
78
+ body
79
+ };
80
+ }
81
+
82
+ const m = /^(\S+)\s+(\S+)\s+HTTP\/[\d.]+$/.exec((firstLine || '').trim());
83
+ return {
84
+ kind: 'single',
85
+ method: m ? m[1] : undefined,
86
+ url: m ? m[2] : undefined,
87
+ headerLines: headerLines.filter((l) => l.trim().length > 0),
88
+ body
89
+ };
90
+ }
91
+
92
+ /** Parses one top-level (or changeset-nested) batch part, recursing into changesets. */
93
+ function parseBatchPart(rawPart, { isResponse }) {
94
+ const { headerBlock, body } = splitHeadersAndBody(rawPart);
95
+ const headers = parseHeaderBlock(headerBlock);
96
+ const contentType = headers['content-type'] || '';
97
+
98
+ if (/multipart\/mixed/i.test(contentType)) {
99
+ const nestedBoundary = extractBoundary(contentType);
100
+ return {
101
+ kind: 'changeset',
102
+ boundary: nestedBoundary,
103
+ parts: nestedBoundary ? splitMultipart(body, nestedBoundary).map((p) => parseBatchPart(p, { isResponse })) : []
104
+ };
105
+ }
106
+
107
+ return parseHttpEnvelope(body, { isResponse });
108
+ }
109
+
110
+ /**
111
+ * @param {string|Buffer} rawBody the $batch request body
112
+ * @param {string} boundary from the request's Content-Type header
113
+ * @returns {object[]} tree of `{ kind: 'single', method, url, ... } | { kind: 'changeset', boundary, parts }`
114
+ */
115
+ function parseBatchRequests(rawBody, boundary) {
116
+ return splitMultipart(rawBody, boundary).map((p) => parseBatchPart(p, { isResponse: false }));
117
+ }
118
+
119
+ /**
120
+ * @param {string|Buffer} rawBody the $batch response body
121
+ * @param {string} boundary from the response's Content-Type header
122
+ * @returns {object[]} tree of `{ kind: 'single', status, statusText, headerLines, body } | { kind: 'changeset', boundary, parts }`
123
+ */
124
+ function parseBatchResponses(rawBody, boundary) {
125
+ return splitMultipart(rawBody, boundary).map((p) => parseBatchPart(p, { isResponse: true }));
126
+ }
127
+
128
+ function serializeEnvelope(envelope) {
129
+ const startLine = `HTTP/1.1 ${envelope.status} ${envelope.statusText}`;
130
+ const headerText = envelope.headerLines.map((l) => `${l}\r\n`).join('');
131
+ return `${startLine}\r\n${headerText}\r\n${envelope.body}`;
132
+ }
133
+
134
+ /**
135
+ * Re-serializes a (possibly mutated) response tree — see parseBatchResponses()
136
+ * — back into a valid `multipart/mixed` body using `boundary`.
137
+ *
138
+ * @param {object[]} items as returned by parseBatchResponses()
139
+ * @param {string} boundary
140
+ * @returns {string}
141
+ */
142
+ function serializeBatchResponses(items, boundary) {
143
+ let out = '';
144
+
145
+ for (const item of items) {
146
+ out += `--${boundary}\r\n`;
147
+
148
+ if (item.kind === 'changeset') {
149
+ out += `Content-Type: multipart/mixed; boundary=${item.boundary}\r\n\r\n`;
150
+ out += serializeBatchResponses(item.parts, item.boundary);
151
+ } else {
152
+ out += `Content-Type: application/http\r\nContent-Transfer-Encoding: binary\r\n\r\n`;
153
+ out += `${serializeEnvelope(item)}\r\n`;
154
+ }
155
+ }
156
+
157
+ out += `--${boundary}--\r\n`;
158
+ return out;
159
+ }
160
+
161
+ module.exports = { extractBoundary, splitMultipart, parseBatchRequests, parseBatchResponses, serializeBatchResponses };
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+
3
+ const { resolveEntityForPath, resolveExpandTarget } = require('./edm');
4
+
5
+ const ODATA_SYSTEM_SEGMENTS = new Set(['$metadata', '$batch']);
6
+
7
+ const METHOD_TO_OPERATION = {
8
+ GET: 'READ',
9
+ POST: 'CREATE',
10
+ PUT: 'UPDATE',
11
+ PATCH: 'UPDATE',
12
+ MERGE: 'UPDATE',
13
+ DELETE: 'DELETE'
14
+ };
15
+
16
+ /**
17
+ * Resolves the OData entity-set (or navigation property) name a request
18
+ * targets, from its URL path alone — no service metadata ($metadata/CSDL)
19
+ * is consulted. Works for both OData V2 (`/sap/opu/odata/sap/ZGW_SRV/Products`)
20
+ * and V4 (`/odata/v4/browse/Books(201)`) path conventions: it's the last
21
+ * path segment, with any `(key)` predicate stripped.
22
+ *
23
+ * Returns undefined for OData system resources ($metadata, $batch) since
24
+ * those don't map to a single configurable entity.
25
+ *
26
+ * Pure function, no HTTP framework dependency.
27
+ *
28
+ * @param {string} url request path or full URL (query string is ignored)
29
+ * @returns {string|undefined}
30
+ */
31
+ function resolveEntitySet(url) {
32
+ if (!url) return undefined;
33
+
34
+ const pathOnly = url.split('?')[0];
35
+ const segments = pathOnly.split('/').filter(Boolean);
36
+ if (segments.length === 0) return undefined;
37
+
38
+ const last = segments[segments.length - 1].replace(/\(.*$/, '');
39
+ if (!last || ODATA_SYSTEM_SEGMENTS.has(last) || last.startsWith('$')) return undefined;
40
+
41
+ return last;
42
+ }
43
+
44
+ /**
45
+ * Maps an HTTP method onto the same CQN-style operation vocabulary the
46
+ * guard already uses for CAP requests (READ/CREATE/UPDATE/DELETE, see
47
+ * lib/adapters/cap.js's resolveOperation) — so one cap-mcp-guard.yaml
48
+ * applies unchanged whether traffic arrives via CAP's before/after hooks
49
+ * or raw OData HTTP.
50
+ *
51
+ * @param {string} method
52
+ * @returns {string|undefined}
53
+ */
54
+ function resolveOperation(method) {
55
+ return METHOD_TO_OPERATION[(method || '').toUpperCase()];
56
+ }
57
+
58
+ /**
59
+ * @param {string} url
60
+ * @returns {boolean} true for a $batch request (OData V2 and V4 both use
61
+ * this convention). Its body is a multipart mix of sub-requests this
62
+ * module does not decode, so per-entity masking does not reach inside it
63
+ * — see README "Limitations".
64
+ */
65
+ function isBatchRequest(url) {
66
+ if (!url) return false;
67
+ const pathOnly = url.split('?')[0];
68
+ return pathOnly.split('/').filter(Boolean).pop() === '$batch';
69
+ }
70
+
71
+ /**
72
+ * Extracts the row objects an OData JSON response body carries, regardless
73
+ * of whether it's a V2 collection (`{ d: { results: [...] } }`), a V2
74
+ * single entity (`{ d: {...} }`), a V4 collection (`{ value: [...] }`), or
75
+ * a V4 single entity (a bare entity object carrying `@odata.context`).
76
+ *
77
+ * The returned rows are the SAME object references found inside `body` —
78
+ * mutating a returned row mutates the response body in place, mirroring
79
+ * how CAP's `after` handlers mutate results in place (see
80
+ * lib/core/interceptor.js's applyMask).
81
+ *
82
+ * @param {*} body parsed JSON response body
83
+ * @returns {object[]} row objects (empty array if the shape is unrecognized)
84
+ */
85
+ function extractResultRows(body) {
86
+ if (!body || typeof body !== 'object') return [];
87
+
88
+ if (Array.isArray(body.value)) return body.value;
89
+ if (body.d && Array.isArray(body.d.results)) return body.d.results;
90
+ if (body.d && typeof body.d === 'object') return [body.d];
91
+ if ('@odata.context' in body) return [body];
92
+
93
+ return [];
94
+ }
95
+
96
+ /**
97
+ * Resolves the FULL request path (every segment, not just the last) into
98
+ * a policy-config-friendly entity name. Without `edm`, this is exactly
99
+ * resolveEntitySet()'s single-last-segment heuristic — unchanged behavior.
100
+ * With `edm` (see lib/core/edm.js's parseMetadata()), the whole path is
101
+ * walked hop-by-hop through navigation properties, so a deep path like
102
+ * `Books(201)/author` resolves to `author`'s real target entity instead
103
+ * of the literal string `"author"`.
104
+ *
105
+ * @param {string} url
106
+ * @param {object} [edm] see lib/core/edm.js parseMetadata()
107
+ * @returns {string|undefined}
108
+ */
109
+ function resolveEntityPath(url, edm) {
110
+ if (!edm) return resolveEntitySet(url);
111
+ if (!url) return undefined;
112
+
113
+ const pathOnly = url.split('?')[0];
114
+ const segments = pathOnly
115
+ .split('/')
116
+ .filter(Boolean)
117
+ .map((s) => s.replace(/\(.*$/, ''))
118
+ .filter((s) => s && !ODATA_SYSTEM_SEGMENTS.has(s) && !s.startsWith('$'));
119
+
120
+ return resolveEntityForPath(edm, segments);
121
+ }
122
+
123
+ function nestedRowsFor(row, navName) {
124
+ const value = row && typeof row === 'object' ? row[navName] : undefined;
125
+
126
+ if (Array.isArray(value)) return value;
127
+ if (value && Array.isArray(value.results)) return value.results; // OData V2 expand shape
128
+ if (value && typeof value === 'object' && !('__deferred' in value)) return [value];
129
+
130
+ return [];
131
+ }
132
+
133
+ /**
134
+ * Walks a parsed OData response body and groups every row it contains —
135
+ * the top-level collection/entity plus every `$expand`'ed nested
136
+ * entity/collection reachable through `expandTree` — by the entity name
137
+ * masking rules should be looked up under. Each group's rows are the SAME
138
+ * object references found inside `body` (see extractResultRows), so
139
+ * masking a group's rows mutates the response body in place.
140
+ *
141
+ * Without an `edm`, a nested entity's name is just its nav-property name
142
+ * as it appears in `$expand` — good enough when cap-mcp-guard.yaml happens
143
+ * to key an entity by that same name, but not a real entity-set/type
144
+ * resolution. Pass `edm` (see lib/core/edm.js) to resolve nav properties
145
+ * to their real target entity instead.
146
+ *
147
+ * @param {*} body parsed JSON response body
148
+ * @param {string} rootEntity entity name for the top-level rows
149
+ * @param {object} [expandTree] see lib/core/edm.js parseExpandOption()
150
+ * @param {object} [edm] see lib/core/edm.js parseMetadata()
151
+ * @returns {{ entity: string, rows: object[] }[]}
152
+ */
153
+ function collectMaskableGroups(body, rootEntity, expandTree = {}, edm) {
154
+ const groups = [];
155
+
156
+ function walk(rows, entityName, tree) {
157
+ if (rows.length) groups.push({ entity: entityName, rows });
158
+
159
+ for (const [navName, subTree] of Object.entries(tree)) {
160
+ const nestedEntity = edm ? resolveExpandTarget(edm, entityName, navName) : navName;
161
+ const nestedRows = rows.flatMap((row) => nestedRowsFor(row, navName));
162
+
163
+ walk(nestedRows, nestedEntity, subTree);
164
+ }
165
+ }
166
+
167
+ walk(extractResultRows(body), rootEntity, expandTree);
168
+ return groups;
169
+ }
170
+
171
+ module.exports = {
172
+ resolveEntitySet,
173
+ resolveOperation,
174
+ isBatchRequest,
175
+ extractResultRows,
176
+ resolveEntityPath,
177
+ collectMaskableGroups
178
+ };
package/lib/index.js CHANGED
@@ -2,5 +2,6 @@
2
2
 
3
3
  const { buildContext } = require('./core/context');
4
4
  const { attachInterceptor } = require('./core/interceptor');
5
+ const { odataMcpGuard } = require('./adapters/odata');
5
6
 
6
- module.exports = { buildContext, attachInterceptor };
7
+ module.exports = { buildContext, attachInterceptor, odataMcpGuard };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cap-mcp-guard",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "main": "lib/index.js",
6
6
  "author": "Furkan Aksoy <furkanaksy838@gmail.com>",
@@ -22,6 +22,7 @@
22
22
  "keywords": [
23
23
  "cap",
24
24
  "sap",
25
+ "odata",
25
26
  "mcp",
26
27
  "ai-agent",
27
28
  "policy",