cap-mcp-guard 0.1.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/LICENSE +21 -0
- package/README.md +127 -0
- package/cds-plugin.js +8 -0
- package/lib/adapters/cap.js +80 -0
- package/lib/audit/log.js +62 -0
- package/lib/core/context.js +67 -0
- package/lib/core/interceptor.js +156 -0
- package/lib/index.js +6 -0
- package/lib/otel/exporter.js +104 -0
- package/lib/policy/config.js +106 -0
- package/lib/policy/evaluator.js +56 -0
- package/lib/policy/masking.js +39 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Furkan Aksoy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# cap-mcp-guard
|
|
2
|
+
|
|
3
|
+
CAP MCP Guard is 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.
|
|
4
|
+
|
|
5
|
+
Today, organizations have only two options when exposing SAP CAP business data to AI agents: grant unrestricted access or deny access completely. CAP MCP Guard introduces a third option — controlled, observable, and policy-driven access, without requiring developers to hand-write authorization, masking, and audit logic for every entity.
|
|
6
|
+
|
|
7
|
+
We give CAP developers a standardized, reusable way to enforce AI-agent security, observability, and policy — without hand-writing that logic for every entity. Convention over implementation, in the same spirit as CAP itself.
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
- **Field exposure.** When AI agents connect to CAP entities over MCP, sensitive fields (`CreditCardNo`, `Salary`, national ID numbers, ...) are visible to the agent unless someone filters them by hand, entity by entity.
|
|
12
|
+
- **No visibility.** Which agent accessed which entity, when, how many rows, and how long it took isn't logged anywhere standard. There's no answer when someone asks for an audit trail.
|
|
13
|
+
- **Excessive trust.** Even a "read-only" agent can technically reach every action/function a service exposes through the MCP layer, unless something enforces otherwise. Relying on the agent to "behave" isn't a control.
|
|
14
|
+
- **Enterprise distrust.** Companies want AI agents connected to business data, but security teams block it because nobody can prove what the agent will actually do. CAP MCP Guard is the third option between "wide open" and "no access at all."
|
|
15
|
+
|
|
16
|
+
## How it works
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
AI Request → Intercept → Evaluate Policy → Mask → Execute → Audit → Trace
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
Claude / Joule / Copilot / Custom Agent
|
|
24
|
+
│
|
|
25
|
+
Any MCP Runtime
|
|
26
|
+
(gavdilabs/cap-mcp-plugin, a custom runtime, a future official SAP
|
|
27
|
+
solution — it doesn't matter which)
|
|
28
|
+
│
|
|
29
|
+
┌─────────────────────────┐
|
|
30
|
+
│ cap-mcp-guard │
|
|
31
|
+
│ │
|
|
32
|
+
│ lib/core/ │
|
|
33
|
+
│ ├─ interceptor.js │ → attaches to CAP's srv.before/srv.after hooks
|
|
34
|
+
│ └─ context.js │ → builds an OTel gen_ai.*-shaped, framework-
|
|
35
|
+
│ │ agnostic request context
|
|
36
|
+
│ │
|
|
37
|
+
│ lib/policy/ │ → knows nothing about CAP. Plain JS: Context in,
|
|
38
|
+
│ ├─ config.js │ Decision out.
|
|
39
|
+
│ ├─ evaluator.js │
|
|
40
|
+
│ └─ masking.js │
|
|
41
|
+
│ │
|
|
42
|
+
│ lib/audit/ │
|
|
43
|
+
│ └─ log.js │ → Context + Decision → structured JSON log line
|
|
44
|
+
│ │
|
|
45
|
+
│ lib/otel/ │
|
|
46
|
+
│ └─ exporter.js │ → Context + Decision → a real OTel span
|
|
47
|
+
│ │
|
|
48
|
+
│ lib/adapters/ │
|
|
49
|
+
│ └─ cap.js │ → the ONE place that knows @sap/cds.
|
|
50
|
+
│ │ cds-plugin.js calls this.
|
|
51
|
+
└─────────────────────────┘
|
|
52
|
+
│
|
|
53
|
+
CAP Service
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`lib/policy/` and `lib/core/context.js` never import `@sap/cds` — they only ever see a plain `Context` object and a plain `PolicyDefinition` object, regardless of where either one came from. That's what lets `cap-mcp-guard.yaml` be swapped out for a future CDS-annotation-based source later without touching the engine itself.
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npm install --save-dev cap-mcp-guard
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
CAP auto-discovers `cds-plugin.js` the moment the package is a dependency of your project — no wiring required. Drop a `cap-mcp-guard.yaml` in your project root and it's picked up automatically the next time your CAP server starts.
|
|
65
|
+
|
|
66
|
+
## Configure
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
69
|
+
# cap-mcp-guard.yaml
|
|
70
|
+
mode: enforce # or: observe (dry-run — logs/traces what would happen, blocks nothing)
|
|
71
|
+
|
|
72
|
+
entities:
|
|
73
|
+
Orders:
|
|
74
|
+
mask:
|
|
75
|
+
- CreditCard
|
|
76
|
+
- Salary
|
|
77
|
+
maxRows: 100
|
|
78
|
+
allowTools:
|
|
79
|
+
- ReadOrders
|
|
80
|
+
|
|
81
|
+
Customers:
|
|
82
|
+
mask:
|
|
83
|
+
- Email
|
|
84
|
+
- Phone
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- Entities not listed here are fully accessible — this is opt-in by design; you don't have to configure every entity up front.
|
|
88
|
+
- No config file at all? The guard runs in pass-through mode (a `console.warn` tells you so) rather than crashing your server.
|
|
89
|
+
- A config file that exists but fails to parse *does* fail loudly — a broken config shouldn't fail silently.
|
|
90
|
+
|
|
91
|
+
## What you get, per request
|
|
92
|
+
|
|
93
|
+
- **Masking** — in `enforce` mode, fields listed under `mask` are replaced with `'***MASKED***'` in the real response. In `observe` mode nothing is touched; the guard only computes what *would* happen.
|
|
94
|
+
- **Audit log** — every request produces a structured JSON line (Context + Decision), to stdout and/or a file you choose.
|
|
95
|
+
- **OpenTelemetry spans** — every request also becomes a real span via `@opentelemetry/api`. If your app already has an OTel SDK configured (any OTLP-compatible backend — Grafana, Jaeger, Datadog, SAP Cloud Logging), the guard's spans just show up there, correctly linked into the caller's trace via W3C Trace Context (`traceparent`/`tracestate`) when present — no extra mapping needed, because the context schema was built against OTel's GenAI semantic conventions (`gen_ai.*`) from the start.
|
|
96
|
+
|
|
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
|
+
|
|
99
|
+
## Try it
|
|
100
|
+
|
|
101
|
+
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`.
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
cd examples/bookshop
|
|
105
|
+
npm install
|
|
106
|
+
npm test # runs enforce/observe/audit/OTel integration tests against a real CAP service
|
|
107
|
+
npm start # boots a real server at localhost:4004 — flip cap-mcp-guard.yaml to `mode: enforce`
|
|
108
|
+
# and hit /odata/v4/browse/Books to see masking happen live
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Coming soon (not in v1)
|
|
112
|
+
|
|
113
|
+
- `@mcp.policy`-style CDS annotations as an alternative to `cap-mcp-guard.yaml`
|
|
114
|
+
- Approval workflows (human-in-the-loop for sensitive operations)
|
|
115
|
+
- Rate limiting and a dashboard UI
|
|
116
|
+
- Adapters for frameworks other than CAP
|
|
117
|
+
- Actually blocking a request when `allowTools`/`maxRows` is violated (today those are computed and reported, not enforced)
|
|
118
|
+
|
|
119
|
+
## Development
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npm test # unit tests for lib/core, lib/policy, lib/audit, lib/otel, lib/adapters
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
package/cds-plugin.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// CAP auto-discovers this file (SAP's "cds-plugin.js" convention) as soon as
|
|
4
|
+
// cap-mcp-guard is a dependency of the host project — no wiring required.
|
|
5
|
+
const cds = require('@sap/cds');
|
|
6
|
+
const { registerCapMcpGuard } = require('./lib/adapters/cap');
|
|
7
|
+
|
|
8
|
+
registerCapMcpGuard(cds);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const { attachInterceptor } = require('../core/interceptor');
|
|
6
|
+
const { loadConfig } = require('../policy/config');
|
|
7
|
+
const { logAudit } = require('../audit/log');
|
|
8
|
+
const { exportSpan } = require('../otel/exporter');
|
|
9
|
+
|
|
10
|
+
const CONFIG_FILE_NAME = 'cap-mcp-guard.yaml';
|
|
11
|
+
const NOT_FOUND_PREFIX = `${CONFIG_FILE_NAME} not found`;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Loads cap-mcp-guard.yaml from the host CAP project's root. A missing
|
|
15
|
+
* config is a valid "not configured yet" state — falls back to a
|
|
16
|
+
* pass-through PolicyDefinition (every entity is opt-in, so this is
|
|
17
|
+
* equivalent to no policy at all). A config that exists but fails to
|
|
18
|
+
* parse is a user mistake and is NOT swallowed — it propagates so the
|
|
19
|
+
* bad config gets noticed rather than silently ignored.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} cds the @sap/cds module (used only for cds.root)
|
|
22
|
+
*/
|
|
23
|
+
function resolvePolicyDefinition(cds) {
|
|
24
|
+
const configPath = path.join(cds.root || process.cwd(), CONFIG_FILE_NAME);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
return loadConfig(configPath);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (err.message.startsWith(NOT_FOUND_PREFIX)) {
|
|
30
|
+
console.warn('[cap-mcp-guard] no config found, running in pass-through mode (no policies enforced)');
|
|
31
|
+
return { mode: 'observe', entities: {} };
|
|
32
|
+
}
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The single CAP-specific connection point in this package. Everything
|
|
39
|
+
* under lib/core and lib/policy is framework-agnostic; this file is where
|
|
40
|
+
* @sap/cds concepts (served services, cds.ApplicationService, cds.root)
|
|
41
|
+
* are known.
|
|
42
|
+
*
|
|
43
|
+
* cds-plugin.js is the only caller of this function in production; tests
|
|
44
|
+
* call it directly against a real or fake CAP service.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} cds the @sap/cds module (or a compatible facade)
|
|
47
|
+
* @param {object} [options] forwarded to attachInterceptor (see interceptor.js)
|
|
48
|
+
* @param {object} [options.policyDefinition] when omitted, loaded from
|
|
49
|
+
* cap-mcp-guard.yaml at the project root (see resolvePolicyDefinition)
|
|
50
|
+
* @param {object|false} [options.audit] forwarded to logAudit()'s write
|
|
51
|
+
* options (`{ stdout, filePath }`); pass `false` to disable audit
|
|
52
|
+
* logging entirely.
|
|
53
|
+
* @param {object|false} [options.otel] forwarded to exportSpan()'s options
|
|
54
|
+
* (`{ tracer }`); pass `false` to skip OTel span export entirely. With
|
|
55
|
+
* no host-configured OTel SDK this is already a harmless no-op, so the
|
|
56
|
+
* default is to always attempt it.
|
|
57
|
+
* @param {(decision: object, context: object, req: object) => void} [options.onDecision]
|
|
58
|
+
* called in addition to the built-in audit log and OTel export, not
|
|
59
|
+
* instead of them — all that apply run for every request.
|
|
60
|
+
*/
|
|
61
|
+
function registerCapMcpGuard(cds, options = {}) {
|
|
62
|
+
const policyDefinition = options.policyDefinition || resolvePolicyDefinition(cds);
|
|
63
|
+
const { onDecision: userOnDecision, audit, otel } = options;
|
|
64
|
+
|
|
65
|
+
const onDecision = (decision, context, req) => {
|
|
66
|
+
if (audit !== false) logAudit(context, decision, audit);
|
|
67
|
+
if (otel !== false) exportSpan(context, decision, otel);
|
|
68
|
+
if (typeof userOnDecision === 'function') userOnDecision(decision, context, req);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
cds.on('served', () => {
|
|
72
|
+
for (const srv of Object.values(cds.services)) {
|
|
73
|
+
if (srv instanceof cds.ApplicationService) {
|
|
74
|
+
attachInterceptor(srv, { ...options, policyDefinition, onDecision });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { registerCapMcpGuard };
|
package/lib/audit/log.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds the structured audit entry for a single request: the full guard
|
|
7
|
+
* Context (lib/core/context.js) alongside the Decision computed for it
|
|
8
|
+
* (lib/policy/evaluator.js). Pure — no I/O, no CAP dependency.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} context
|
|
11
|
+
* @param {object} decision
|
|
12
|
+
* @returns {{ timestamp: string, context: object, decision: object }}
|
|
13
|
+
*/
|
|
14
|
+
function buildAuditEntry(context, decision) {
|
|
15
|
+
return {
|
|
16
|
+
timestamp: context.timestamp,
|
|
17
|
+
context,
|
|
18
|
+
decision
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Writes an audit entry as a single line of JSON (one entry per line, so
|
|
24
|
+
* log files stay grep/tail-friendly). Always writes to stdout unless
|
|
25
|
+
* `stdout: false`; additionally appends to `filePath` if given. Plain
|
|
26
|
+
* Node `fs` — no CAP dependency.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} entry see buildAuditEntry()
|
|
29
|
+
* @param {object} [options]
|
|
30
|
+
* @param {boolean} [options.stdout] defaults to true
|
|
31
|
+
* @param {string} [options.filePath] optional file to append the line to
|
|
32
|
+
*/
|
|
33
|
+
function writeAuditEntry(entry, options = {}) {
|
|
34
|
+
const { stdout = true, filePath } = options;
|
|
35
|
+
const line = JSON.stringify(entry);
|
|
36
|
+
|
|
37
|
+
if (stdout) {
|
|
38
|
+
console.log(line);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (filePath) {
|
|
42
|
+
fs.appendFileSync(filePath, line + '\n');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Convenience combining buildAuditEntry() + writeAuditEntry() — the
|
|
48
|
+
* function lib/adapters/cap.js wires as the interceptor's onDecision
|
|
49
|
+
* callback by default.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} context
|
|
52
|
+
* @param {object} decision
|
|
53
|
+
* @param {object} [options] see writeAuditEntry()
|
|
54
|
+
* @returns {object} the entry that was written
|
|
55
|
+
*/
|
|
56
|
+
function logAudit(context, decision, options = {}) {
|
|
57
|
+
const entry = buildAuditEntry(context, decision);
|
|
58
|
+
writeAuditEntry(entry, options);
|
|
59
|
+
return entry;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { buildAuditEntry, writeAuditEntry, logAudit };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds a framework-agnostic request context object, shaped after the
|
|
5
|
+
* OpenTelemetry GenAI semantic conventions (`gen_ai.*`) and the W3C Trace
|
|
6
|
+
* Context fields propagated via MCP `_meta` (SEP-414: traceparent/tracestate).
|
|
7
|
+
*
|
|
8
|
+
* Pure function: given the same `input` and `now`, it always returns the
|
|
9
|
+
* same shape. No CAP or OTel dependency lives here.
|
|
10
|
+
*
|
|
11
|
+
* @param {object} input
|
|
12
|
+
* @param {string} [input.agentId]
|
|
13
|
+
* @param {string} [input.agentName]
|
|
14
|
+
* @param {string} [input.model]
|
|
15
|
+
* @param {string} [input.traceparent]
|
|
16
|
+
* @param {string} [input.tracestate]
|
|
17
|
+
* @param {string} [input.entity]
|
|
18
|
+
* @param {string} [input.operation]
|
|
19
|
+
* @param {string} [input.tenant]
|
|
20
|
+
* @param {string} [input.user]
|
|
21
|
+
* @param {string} [input.session]
|
|
22
|
+
* @param {string} [input.timestamp]
|
|
23
|
+
* @param {number} [input.rowCount]
|
|
24
|
+
* @param {number} [input.durationMs]
|
|
25
|
+
* @param {object} [deps]
|
|
26
|
+
* @param {() => string} [deps.now] injectable clock, defaults to ISO now
|
|
27
|
+
* @returns {object} context object matching the guard's context schema
|
|
28
|
+
*/
|
|
29
|
+
function buildContext(input = {}, deps = {}) {
|
|
30
|
+
const { now = () => new Date().toISOString() } = deps;
|
|
31
|
+
|
|
32
|
+
const {
|
|
33
|
+
agentId,
|
|
34
|
+
agentName,
|
|
35
|
+
model,
|
|
36
|
+
traceparent,
|
|
37
|
+
tracestate,
|
|
38
|
+
entity,
|
|
39
|
+
operation,
|
|
40
|
+
tenant,
|
|
41
|
+
user,
|
|
42
|
+
session,
|
|
43
|
+
timestamp,
|
|
44
|
+
rowCount,
|
|
45
|
+
durationMs
|
|
46
|
+
} = input;
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
'gen_ai.agent.id': agentId,
|
|
50
|
+
'gen_ai.agent.name': agentName,
|
|
51
|
+
'gen_ai.request.model': model,
|
|
52
|
+
|
|
53
|
+
traceparent,
|
|
54
|
+
tracestate,
|
|
55
|
+
|
|
56
|
+
entity,
|
|
57
|
+
operation,
|
|
58
|
+
tenant,
|
|
59
|
+
user,
|
|
60
|
+
session,
|
|
61
|
+
timestamp: timestamp || now(),
|
|
62
|
+
rowCount: typeof rowCount === 'number' ? rowCount : undefined,
|
|
63
|
+
durationMs: typeof durationMs === 'number' ? durationMs : undefined
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { buildContext };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { buildContext } = require('./context');
|
|
4
|
+
const { evaluate } = require('../policy/evaluator');
|
|
5
|
+
const { maskFields } = require('../policy/masking');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reads W3C Trace Context fields propagated by the MCP runtime.
|
|
9
|
+
* SEP-414 carries these via `_meta.traceparent` / `_meta.tracestate`; HTTP
|
|
10
|
+
* transports may also set the headers directly. Missing values are fine —
|
|
11
|
+
* this never throws for a request that has neither.
|
|
12
|
+
*/
|
|
13
|
+
function extractTraceContext(req) {
|
|
14
|
+
const meta = (req && req._meta) || (req && req.data && req.data._meta) || {};
|
|
15
|
+
const headers = (req && req.http && req.http.req && req.http.req.headers) || {};
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
traceparent: meta.traceparent || headers.traceparent,
|
|
19
|
+
tracestate: meta.tracestate || headers.tracestate
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reads AI-agent identity fields. Until an MCP runtime is wired in, these
|
|
25
|
+
* arrive (if at all) via the same `_meta` bag as the trace context.
|
|
26
|
+
*/
|
|
27
|
+
function extractAgentInfo(req) {
|
|
28
|
+
const meta = (req && req._meta) || (req && req.data && req.data._meta) || {};
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
agentId: meta['gen_ai.agent.id'],
|
|
32
|
+
agentName: meta['gen_ai.agent.name'],
|
|
33
|
+
model: meta['gen_ai.request.model']
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveEntity(req) {
|
|
38
|
+
if (!req) return undefined;
|
|
39
|
+
if (typeof req.entity === 'string') return req.entity;
|
|
40
|
+
if (req.target && req.target.name) return req.target.name;
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function resolveOperation(req) {
|
|
45
|
+
return (req && req.event) || undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveRowCount(results) {
|
|
49
|
+
if (Array.isArray(results)) return results.length;
|
|
50
|
+
if (results === undefined || results === null) return 0;
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Applies masked field values onto the live CAP results (single row or
|
|
56
|
+
* array of rows), mutating them in place — CAP reflects in-place edits
|
|
57
|
+
* made in an `after` handler back into the actual response. maskFields()
|
|
58
|
+
* itself stays pure; this is the one place that turns its (immutable)
|
|
59
|
+
* output into a real effect on the request.
|
|
60
|
+
*/
|
|
61
|
+
function applyMask(results, fieldsToMask) {
|
|
62
|
+
const masked = maskFields(results, fieldsToMask);
|
|
63
|
+
const items = Array.isArray(results) ? results : [results];
|
|
64
|
+
const maskedItems = Array.isArray(masked) ? masked : [masked];
|
|
65
|
+
|
|
66
|
+
items.forEach((item, i) => {
|
|
67
|
+
if (!item || typeof item !== 'object') return;
|
|
68
|
+
const maskedItem = maskedItems[i];
|
|
69
|
+
for (const field of fieldsToMask) {
|
|
70
|
+
if (Object.prototype.hasOwnProperty.call(item, field)) {
|
|
71
|
+
item[field] = maskedItem[field];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Attaches request interception to a CAP service instance, producing a
|
|
79
|
+
* guard context (see context.js) for every request that passes through it.
|
|
80
|
+
*
|
|
81
|
+
* If a `policyDefinition` is supplied, each context is also evaluated (see
|
|
82
|
+
* policy/evaluator.js) into a Decision. In `enforce` mode, any fields the
|
|
83
|
+
* Decision names in `fieldsToMask` are redacted on the real response before
|
|
84
|
+
* it reaches the caller. In `observe` mode the response is never touched —
|
|
85
|
+
* the Decision is still computed and handed to `onDecision`, but nothing
|
|
86
|
+
* about the request's outcome changes. Without a `policyDefinition`, this
|
|
87
|
+
* only observes and builds context — the M1 behavior.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} srv a CAP service exposing before()/after() (duck-typed —
|
|
90
|
+
* this module never imports @sap/cds)
|
|
91
|
+
* @param {object} [options]
|
|
92
|
+
* @param {(context: object, req: object) => void} [options.onContext]
|
|
93
|
+
* called with the built context after each request completes
|
|
94
|
+
* @param {object} [options.policyDefinition] see lib/policy/config.js —
|
|
95
|
+
* when omitted, no policy evaluation or masking happens
|
|
96
|
+
* @param {(decision: object, context: object, req: object) => void} [options.onDecision]
|
|
97
|
+
* called with the Decision (and the Context it was computed from) after
|
|
98
|
+
* each request completes, regardless of mode — this is the extension
|
|
99
|
+
* point M5 (audit log) uses
|
|
100
|
+
* @param {() => string} [options.now] injectable clock, forwarded to buildContext
|
|
101
|
+
*/
|
|
102
|
+
function attachInterceptor(srv, options = {}) {
|
|
103
|
+
if (!srv || typeof srv.before !== 'function' || typeof srv.after !== 'function') {
|
|
104
|
+
throw new Error('attachInterceptor requires a CAP service exposing before()/after() hooks');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const { onContext, policyDefinition, onDecision, now } = options;
|
|
108
|
+
const clockDeps = now ? { now } : {};
|
|
109
|
+
|
|
110
|
+
srv.before('*', (req) => {
|
|
111
|
+
if (!req) return;
|
|
112
|
+
req._mcpGuardStartedAt = process.hrtime.bigint();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
srv.after('*', (results, req) => {
|
|
116
|
+
if (!req) return;
|
|
117
|
+
|
|
118
|
+
const startedAt = req._mcpGuardStartedAt;
|
|
119
|
+
const durationMs = startedAt === undefined
|
|
120
|
+
? undefined
|
|
121
|
+
: Number(process.hrtime.bigint() - startedAt) / 1e6;
|
|
122
|
+
|
|
123
|
+
const context = buildContext(
|
|
124
|
+
{
|
|
125
|
+
...extractAgentInfo(req),
|
|
126
|
+
...extractTraceContext(req),
|
|
127
|
+
entity: resolveEntity(req),
|
|
128
|
+
operation: resolveOperation(req),
|
|
129
|
+
tenant: req.tenant,
|
|
130
|
+
user: req.user && req.user.id,
|
|
131
|
+
session: req.id,
|
|
132
|
+
rowCount: resolveRowCount(results),
|
|
133
|
+
durationMs
|
|
134
|
+
},
|
|
135
|
+
clockDeps
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (typeof onContext === 'function') {
|
|
139
|
+
onContext(context, req);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (policyDefinition) {
|
|
143
|
+
const decision = evaluate(context, policyDefinition);
|
|
144
|
+
|
|
145
|
+
if (decision.mode === 'enforce' && decision.fieldsToMask.length > 0) {
|
|
146
|
+
applyMask(results, decision.fieldsToMask);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (typeof onDecision === 'function') {
|
|
150
|
+
onDecision(decision, context, req);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = { attachInterceptor };
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { trace, context: otelContext, propagation, SpanStatusCode } = require('@opentelemetry/api');
|
|
4
|
+
|
|
5
|
+
const TRACER_NAME = 'cap-mcp-guard';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Maps a guard Context + Decision onto OTel span attributes. The Context's
|
|
9
|
+
* gen_ai.* keys are already valid OTel GenAI semantic-convention attribute
|
|
10
|
+
* names (that's why context.js was shaped this way back in M1) so they're
|
|
11
|
+
* passed through as-is; cap-mcp-guard's own fields get a namespaced prefix
|
|
12
|
+
* so they never collide with attributes a host app might already set.
|
|
13
|
+
*
|
|
14
|
+
* Pure — no OTel SDK calls, no I/O.
|
|
15
|
+
*
|
|
16
|
+
* @param {object} context see lib/core/context.js
|
|
17
|
+
* @param {object} decision see lib/policy/evaluator.js
|
|
18
|
+
* @returns {object} flat attribute map safe to pass to span.setAttributes()
|
|
19
|
+
*/
|
|
20
|
+
function buildSpanAttributes(context, decision) {
|
|
21
|
+
const attributes = {};
|
|
22
|
+
|
|
23
|
+
for (const [key, value] of Object.entries(context)) {
|
|
24
|
+
if (value === undefined) continue;
|
|
25
|
+
if (key === 'traceparent' || key === 'tracestate') continue;
|
|
26
|
+
attributes[key] = value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
attributes['cap_mcp_guard.mode'] = decision.mode;
|
|
30
|
+
attributes['cap_mcp_guard.allowed'] = decision.allowed;
|
|
31
|
+
attributes['cap_mcp_guard.fields_masked'] = decision.fieldsToMask;
|
|
32
|
+
attributes['cap_mcp_guard.row_limit_exceeded'] = decision.rowLimitExceeded;
|
|
33
|
+
|
|
34
|
+
if (decision.maxRows !== null && decision.maxRows !== undefined) {
|
|
35
|
+
attributes['cap_mcp_guard.max_rows'] = decision.maxRows;
|
|
36
|
+
}
|
|
37
|
+
if (decision.reason) {
|
|
38
|
+
attributes['cap_mcp_guard.reason'] = decision.reason;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return attributes;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolves the parent OTel context from the Context's W3C traceparent/
|
|
46
|
+
* tracestate (SEP-414), so a span started here links into whatever trace
|
|
47
|
+
* the calling MCP runtime is already part of. If the host application
|
|
48
|
+
* hasn't registered a propagator (or the request carried no traceparent),
|
|
49
|
+
* this is a harmless no-op and the span simply starts its own trace.
|
|
50
|
+
*/
|
|
51
|
+
function extractParentContext(context) {
|
|
52
|
+
if (!context.traceparent) return otelContext.active();
|
|
53
|
+
|
|
54
|
+
const carrier = { traceparent: context.traceparent };
|
|
55
|
+
if (context.tracestate) carrier.tracestate = context.tracestate;
|
|
56
|
+
|
|
57
|
+
return propagation.extract(otelContext.active(), carrier);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Records a single request's Context + Decision as a completed OTel span.
|
|
62
|
+
*
|
|
63
|
+
* Per OTel guidance for libraries (as opposed to applications), this never
|
|
64
|
+
* registers a global TracerProvider or exporter itself — it only calls
|
|
65
|
+
* trace.getTracer(), so it automatically ships spans through whatever OTLP
|
|
66
|
+
* exporter (or none at all) the host application has configured. Without a
|
|
67
|
+
* configured SDK, trace.getTracer() returns a no-op tracer and this is a
|
|
68
|
+
* safe no-op, consistent with the rest of the guard's "no config = pass
|
|
69
|
+
* through" behavior.
|
|
70
|
+
*
|
|
71
|
+
* The span is created retroactively with the request's real start/end
|
|
72
|
+
* time (derived from context.timestamp and context.durationMs) rather
|
|
73
|
+
* than wrapping the live request, since this runs from the `after` phase
|
|
74
|
+
* once the request has already completed.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} context see lib/core/context.js
|
|
77
|
+
* @param {object} decision see lib/policy/evaluator.js
|
|
78
|
+
* @param {object} [options]
|
|
79
|
+
* @param {object} [options.tracer] injectable OTel tracer, defaults to
|
|
80
|
+
* trace.getTracer('cap-mcp-guard')
|
|
81
|
+
* @returns {object} the ended span
|
|
82
|
+
*/
|
|
83
|
+
function exportSpan(context, decision, options = {}) {
|
|
84
|
+
const { tracer = trace.getTracer(TRACER_NAME) } = options;
|
|
85
|
+
|
|
86
|
+
const endTimeMs = Date.parse(context.timestamp);
|
|
87
|
+
const startTimeMs =
|
|
88
|
+
typeof context.durationMs === 'number' && Number.isFinite(endTimeMs)
|
|
89
|
+
? endTimeMs - context.durationMs
|
|
90
|
+
: endTimeMs;
|
|
91
|
+
|
|
92
|
+
const spanName = [context.operation, context.entity].filter(Boolean).join(' ') || 'cap-mcp-guard.request';
|
|
93
|
+
const parentOtelContext = extractParentContext(context);
|
|
94
|
+
|
|
95
|
+
const span = tracer.startSpan(spanName, { startTime: startTimeMs }, parentOtelContext);
|
|
96
|
+
|
|
97
|
+
span.setAttributes(buildSpanAttributes(context, decision));
|
|
98
|
+
span.setStatus({ code: decision.allowed ? SpanStatusCode.OK : SpanStatusCode.ERROR });
|
|
99
|
+
span.end(endTimeMs);
|
|
100
|
+
|
|
101
|
+
return span;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { buildSpanAttributes, exportSpan, TRACER_NAME };
|
|
@@ -0,0 +1,106 @@
|
|
|
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 };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Evaluates a request Context (lib/core/context.js) against a
|
|
5
|
+
* PolicyDefinition (lib/policy/config.js) and produces a Decision — what
|
|
6
|
+
* *should* happen for this request. Nothing is enforced here: no fields are
|
|
7
|
+
* removed, no request is blocked, nothing is logged. Those are M4 (masking)
|
|
8
|
+
* and M5 (audit)'s job.
|
|
9
|
+
*
|
|
10
|
+
* Pure function: same inputs, same output. No CAP dependency.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} context see lib/core/context.js buildContext() shape
|
|
13
|
+
* @param {object} policyDefinition see lib/policy/config.js parseConfig() shape
|
|
14
|
+
* @returns {object} Decision
|
|
15
|
+
*/
|
|
16
|
+
function evaluate(context, policyDefinition) {
|
|
17
|
+
const entityConfig = policyDefinition.entities[context.entity];
|
|
18
|
+
|
|
19
|
+
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
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { allowTools, maxRows, mask } = entityConfig;
|
|
33
|
+
|
|
34
|
+
let allowed = true;
|
|
35
|
+
let reason = null;
|
|
36
|
+
if (Array.isArray(allowTools) && !allowTools.includes(context.operation)) {
|
|
37
|
+
allowed = false;
|
|
38
|
+
reason = `tool '${context.operation}' not in allowTools for entity '${context.entity}'`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const rowLimitExceeded =
|
|
42
|
+
typeof maxRows === 'number' && typeof context.rowCount === 'number' && context.rowCount > maxRows;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
mode: policyDefinition.mode,
|
|
46
|
+
allowed,
|
|
47
|
+
reason,
|
|
48
|
+
fieldsToMask: mask ?? [],
|
|
49
|
+
rowLimitExceeded,
|
|
50
|
+
maxRows: typeof maxRows === 'number' ? maxRows : null,
|
|
51
|
+
entity: context.entity,
|
|
52
|
+
timestamp: context.timestamp
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { evaluate };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const MASK_VALUE = '***MASKED***';
|
|
4
|
+
|
|
5
|
+
function maskOne(item, fieldsToMask, maskValue) {
|
|
6
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item)) return item;
|
|
7
|
+
|
|
8
|
+
const masked = { ...item };
|
|
9
|
+
for (const field of fieldsToMask) {
|
|
10
|
+
if (Object.prototype.hasOwnProperty.call(masked, field)) {
|
|
11
|
+
masked[field] = maskValue;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return masked;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Replaces the given fields' values with a mask placeholder. Pure and
|
|
19
|
+
* immutable: never mutates `data`, always returns a new object/array. No
|
|
20
|
+
* CAP dependency — operates on plain data.
|
|
21
|
+
*
|
|
22
|
+
* @param {object|object[]|null|undefined} data
|
|
23
|
+
* @param {string[]} fieldsToMask field names to redact (from Decision.fieldsToMask)
|
|
24
|
+
* @param {string} [maskValue] placeholder value, defaults to '***MASKED***'
|
|
25
|
+
* @returns {object|object[]|null|undefined} a new object/array with masked fields
|
|
26
|
+
*/
|
|
27
|
+
function maskFields(data, fieldsToMask, maskValue = MASK_VALUE) {
|
|
28
|
+
if (data === null || data === undefined) return data;
|
|
29
|
+
|
|
30
|
+
const fields = fieldsToMask || [];
|
|
31
|
+
|
|
32
|
+
if (Array.isArray(data)) {
|
|
33
|
+
return data.map((item) => maskOne(item, fields, maskValue));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return maskOne(data, fields, maskValue);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { maskFields, MASK_VALUE };
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cap-mcp-guard",
|
|
3
|
+
"version": "0.1.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
|
+
"mcp",
|
|
26
|
+
"ai-agent",
|
|
27
|
+
"policy",
|
|
28
|
+
"opentelemetry"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=20"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@sap/cds": ">=7"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@opentelemetry/core": "^2.9.0",
|
|
39
|
+
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
40
|
+
"jest": "^30.4.2"
|
|
41
|
+
},
|
|
42
|
+
"jest": {
|
|
43
|
+
"testEnvironment": "node",
|
|
44
|
+
"testPathIgnorePatterns": [
|
|
45
|
+
"/node_modules/",
|
|
46
|
+
"/examples/"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@opentelemetry/api": "^1.9.1",
|
|
51
|
+
"js-yaml": "^5.2.1"
|
|
52
|
+
}
|
|
53
|
+
}
|