@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 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.17 @modelcontextprotocol/sdk@1.31.0
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 pending discovery reports; it does not
165
- wait for active tool calls or action reports. Abruptly terminated hosts can lose
166
- reports. Discovery advertisements never increment tool action or request counts.
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 pending discovery reports at shutdown. Does not wait for running tools. */
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
- createActionProtection({ policyVersion: options.policyVersion, authenticate: options.authenticate, actions: tools, sharedRuntime: options.sharedRuntime });
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, {}, null, { signal: disconnected.signal });
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
- return await guard.run(request.params.name, (request.params.arguments ?? {}), null, { signal });
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.17",
14
+ "version": "0.1.0-alpha.19",
15
15
  "exports": {
16
16
  "./fetch": {
17
17
  "types": "./fetch.d.mts",