@webdecoy/ai-protection 0.1.0-alpha.17 → 0.1.0-alpha.19
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/MCP.md +18 -4
- package/actions.d.mts +1 -1
- package/actions.mjs +3 -1
- package/mcp.d.mts +1 -1
- package/mcp.mjs +17 -8
- package/package.json +1 -1
package/MCP.md
CHANGED
|
@@ -11,7 +11,7 @@ resource authorization.
|
|
|
11
11
|
Available in `0.1.0-alpha.5` and later compatible alpha releases:
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
|
-
npm install @webdecoy/ai-protection@0.1.0-alpha.
|
|
14
|
+
npm install @webdecoy/ai-protection@0.1.0-alpha.19 @modelcontextprotocol/sdk@1.31.0
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
Requires Node 22.22.3+ and MCP SDK **1.31.0**. The MCP SDK is an optional peer, so
|
|
@@ -115,6 +115,12 @@ have unknown work. Never retry writes without application/provider idempotency.
|
|
|
115
115
|
for its runtime prerequisite and application-enforced bounds. Full product
|
|
116
116
|
acceptance remains separate from this adapter's tested contract.
|
|
117
117
|
|
|
118
|
+
From alpha.19, every tool result also carries `_meta["webdecoy.com/action"].actionId`:
|
|
119
|
+
allowed results (merged with your own `_meta`), denials and failed callbacks alike.
|
|
120
|
+
It is the action ID on that call's reported evidence, so the caller or an operator
|
|
121
|
+
can find the exact action in the dashboard. It is a random identifier per call and
|
|
122
|
+
carries no identity, arguments or result content.
|
|
123
|
+
|
|
118
124
|
This is an explicit tools integration, not transparent protection of an existing
|
|
119
125
|
whole MCP server. See the [MCP transport specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports)
|
|
120
126
|
and [authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
|
|
@@ -161,9 +167,17 @@ Discovery uses schema-3 reports on the existing reporting endpoint; deploy a
|
|
|
161
167
|
compatible hosted runtime first. Old runtimes reject these optional reports
|
|
162
168
|
without affecting tool listing. Reporting has bounded pending work and a deadline,
|
|
163
169
|
never blocks authorization, and does not retry. Call `await handler.flush()` at a
|
|
164
|
-
host shutdown/lifecycle boundary to drain
|
|
165
|
-
|
|
166
|
-
|
|
170
|
+
host shutdown/lifecycle boundary to drain discovery and tool-action reports already
|
|
171
|
+
queued when flush begins. From alpha.18, the action reporting queue is shared by
|
|
172
|
+
all calls on one handler and is bounded by `sharedRuntime.maxPendingReports`
|
|
173
|
+
(default 100); discovery uses a separately bounded queue. Full queues drop reports
|
|
174
|
+
without retrying or blocking tool execution.
|
|
175
|
+
|
|
176
|
+
Stop accepting new requests and let active tools complete or cancel before the
|
|
177
|
+
final flush. Flush does not wait for running tools, future reports, arbitrary
|
|
178
|
+
`onEvent`/`onReport` callbacks or dashboard persistence. Reporting deadlines still
|
|
179
|
+
apply; an unavailable/timeout/drop is not delivery success. Abruptly terminated
|
|
180
|
+
hosts can lose reports. Discovery advertisements never increment tool action or request counts.
|
|
167
181
|
|
|
168
182
|
|
|
169
183
|
### Calls before discovery (alpha.8+)
|
package/actions.d.mts
CHANGED
|
@@ -115,4 +115,4 @@ export function createActionProtection<T>(options: {
|
|
|
115
115
|
actions: Record<string, ActionDefinition>;
|
|
116
116
|
/** Local best-effort observer. sharedRuntime enables independent hosted reporting. */
|
|
117
117
|
onEvent?(event: ActionEvent): void | Promise<void>;
|
|
118
|
-
}): {flush():Promise<void>;run(action: string, args: ActionInput, authenticationContext: T, options?: {signal?: AbortSignal}): Promise<unknown>};
|
|
118
|
+
}): {flush():Promise<void>;run(action: string, args: ActionInput, authenticationContext: T, options?: {signal?: AbortSignal; /** Receives this invocation's action ID before any check runs. */ onAction?(actionId: string): void}): Promise<unknown>};
|
package/actions.mjs
CHANGED
|
@@ -82,8 +82,10 @@ export function createActionProtection(options) {
|
|
|
82
82
|
if (!actions.size || actions.size > 128) throw Error('Expected 1–128 actions');
|
|
83
83
|
const runtime=prepareActionRuntime(options,actions);
|
|
84
84
|
let pendingEvents = 0;
|
|
85
|
-
return Object.freeze({flush:async()=>{await runtime?.flush();},async run(name, input, authenticationContext, {signal} = {}) {
|
|
85
|
+
return Object.freeze({flush:async()=>{await runtime?.flush();},async run(name, input, authenticationContext, {signal, onAction} = {}) {
|
|
86
86
|
const actionId = randomUUID();
|
|
87
|
+
// Lets an adapter return the action ID to its caller; never affects the decision.
|
|
88
|
+
try { onAction?.(actionId); } catch {}
|
|
87
89
|
// Unknown caller-controlled action strings are never placed in evidence.
|
|
88
90
|
const action = actions.get(name), eventAction = action ? name : 'unregistered';
|
|
89
91
|
let attempted = false,completed=false,lease,work,callerEvidence;
|
package/mcp.d.mts
CHANGED
|
@@ -44,6 +44,6 @@ export interface ProtectedMCPOptions {
|
|
|
44
44
|
*/
|
|
45
45
|
export function createProtectedMCPHandler(options: ProtectedMCPOptions):
|
|
46
46
|
((request: IncomingMessage, response: ServerResponse) => Promise<void>) & {
|
|
47
|
-
/** Drain
|
|
47
|
+
/** Drain already queued discovery/action reports within reporting deadlines. Does not wait for running tools, future reports or observer callbacks. */
|
|
48
48
|
flush(): Promise<void>;
|
|
49
49
|
};
|
package/mcp.mjs
CHANGED
|
@@ -50,7 +50,11 @@ export function createProtectedMCPHandler(options) {
|
|
|
50
50
|
if (options.discovery) for (const [name, tool] of Object.entries(tools))
|
|
51
51
|
tool.toolSchema = Object.freeze({serverId:options.discovery.serverId,hash:hashes[name],...(decoys.has(name)?{decoy:decoys.get(name)}:{effect:inferToolEffect(name,tool.inputSchema,tool.annotations),permissions:Object.freeze({schema:1,required_scopes:tool.requiredScopes.length,application_authorization:true,additional_policy:typeof tool.policy==='function'})})});
|
|
52
52
|
// Validate after discovery supplies the schema required by tool pauses.
|
|
53
|
-
|
|
53
|
+
if (typeof options.authenticate !== 'function') throw Error('Invalid MCP authenticator');
|
|
54
|
+
// One bounded reporting runtime per handler. The caller is passed per run,
|
|
55
|
+
// never held in mutable shared request state. HTTP authentication stays above
|
|
56
|
+
// dispatch; the action boundary independently validates and snapshots it.
|
|
57
|
+
const guard = createActionProtection({ policyVersion: options.policyVersion, authenticate: caller => caller, actions: tools, sharedRuntime: options.sharedRuntime, onEvent: options.onEvent });
|
|
54
58
|
const runtime = options.sharedRuntime && { ...options.sharedRuntime };
|
|
55
59
|
const serverId = options.discovery?.serverId;
|
|
56
60
|
const catalogReporter = options.discovery ? createReporter({
|
|
@@ -177,7 +181,6 @@ export function createProtectedMCPHandler(options) {
|
|
|
177
181
|
res.end();
|
|
178
182
|
return;
|
|
179
183
|
}
|
|
180
|
-
const guard = createActionProtection({ policyVersion: options.policyVersion, authenticate: () => caller, actions: tools, sharedRuntime: options.sharedRuntime, onEvent: options.onEvent });
|
|
181
184
|
// Scope escalation belongs at HTTP level, before the SDK opens an SSE stream.
|
|
182
185
|
if (message.method === 'tools/call' && typeof message.params?.name === 'string') {
|
|
183
186
|
const definition = Object.hasOwn(tools, message.params.name) ? tools[message.params.name] : undefined;
|
|
@@ -185,7 +188,7 @@ export function createProtectedMCPHandler(options) {
|
|
|
185
188
|
// Run the same admission path for its sanitized denial evidence. It cannot
|
|
186
189
|
// dispatch with a missing required scope, regardless of discovery results.
|
|
187
190
|
try {
|
|
188
|
-
await guard.run(message.params.name, {},
|
|
191
|
+
await guard.run(message.params.name, {}, caller, { signal: disconnected.signal });
|
|
189
192
|
}
|
|
190
193
|
catch (e) {
|
|
191
194
|
if (!(e instanceof ActionDenied))
|
|
@@ -224,20 +227,26 @@ export function createProtectedMCPHandler(options) {
|
|
|
224
227
|
throw new McpError(ErrorCode.InvalidParams, 'Tool unavailable');
|
|
225
228
|
running.started = true;
|
|
226
229
|
const signal = AbortSignal.any([extra.signal, disconnected.signal, running.controller.signal]);
|
|
230
|
+
// The action ID matches this call's reported evidence, so the caller can find it in the dashboard.
|
|
231
|
+
let actionId;
|
|
232
|
+
const action = () => ({ 'webdecoy.com/action': { actionId } });
|
|
227
233
|
try {
|
|
228
|
-
|
|
234
|
+
const result = await guard.run(request.params.name, (request.params.arguments ?? {}), caller, { signal, onAction: id => { actionId = id; } });
|
|
235
|
+
if (!result || typeof result !== 'object' || Array.isArray(result))
|
|
236
|
+
return result;
|
|
237
|
+
const meta = result._meta && typeof result._meta === 'object' && !Array.isArray(result._meta) ? result._meta : {};
|
|
238
|
+
return { ...result, _meta: { ...meta, ...action() } };
|
|
229
239
|
}
|
|
230
240
|
catch (e) {
|
|
231
241
|
if (signal.aborted)
|
|
232
242
|
throw new McpError(ErrorCode.InternalError, 'Request cancelled');
|
|
233
243
|
if (e instanceof ActionDenied)
|
|
234
244
|
return { isError: true, content: [{ type: 'text', text: `Action denied: ${e.reason}` }],
|
|
235
|
-
_meta: { 'webdecoy.com/action-error': { reason: e.reason, status: e.status, ...(e.retryAfterSeconds ? { retryAfterSeconds: e.retryAfterSeconds } : {}) } } };
|
|
236
|
-
return { isError: true, content: [{ type: 'text', text: 'Action failed; outcome may be unknown' }] };
|
|
245
|
+
_meta: { ...action(), 'webdecoy.com/action-error': { reason: e.reason, status: e.status, ...(e.retryAfterSeconds ? { retryAfterSeconds: e.retryAfterSeconds } : {}) } } };
|
|
246
|
+
return { isError: true, content: [{ type: 'text', text: 'Action failed; outcome may be unknown' }], _meta: action() };
|
|
237
247
|
}
|
|
238
248
|
finally {
|
|
239
249
|
cleanup();
|
|
240
|
-
void guard.flush();
|
|
241
250
|
}
|
|
242
251
|
});
|
|
243
252
|
await server.connect(transport);
|
|
@@ -250,5 +259,5 @@ export function createProtectedMCPHandler(options) {
|
|
|
250
259
|
res.destroy();
|
|
251
260
|
}
|
|
252
261
|
};
|
|
253
|
-
return Object.assign(handle, { flush: async () => { await catalogReporter?.flush(); } });
|
|
262
|
+
return Object.assign(handle, { flush: async () => { await Promise.all([catalogReporter?.flush(), guard.flush()]); } });
|
|
254
263
|
}
|
package/package.json
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"test:types": "tsc --strict --noEmit --module nodenext --target es2022 test/types.mts test/mcp-types.mts",
|
|
12
12
|
"test:workers": "node --test test/workers/*.test.mjs"
|
|
13
13
|
},
|
|
14
|
-
"version": "0.1.0-alpha.
|
|
14
|
+
"version": "0.1.0-alpha.19",
|
|
15
15
|
"exports": {
|
|
16
16
|
"./fetch": {
|
|
17
17
|
"types": "./fetch.d.mts",
|