@webdecoy/ai-protection 0.1.0-alpha.6 → 0.1.0-alpha.8
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 +71 -1
- package/action-runtime.mjs +1 -1
- package/actions.d.mts +7 -0
- package/actions.mjs +6 -2
- package/mcp.d.mts +9 -2
- package/mcp.mjs +40 -5
- 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.8 @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
|
|
@@ -120,3 +120,73 @@ whole MCP server. See the [MCP transport specification](https://modelcontextprot
|
|
|
120
120
|
and [authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
|
|
121
121
|
|
|
122
122
|
For weighted search/export limits and tenant concurrency, see [bounded tool work](WORK.md).
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
## Opt-in tool discovery (alpha.7+)
|
|
126
|
+
|
|
127
|
+
Add these options alongside your existing `tools` and authentication configuration:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
sharedRuntime: {
|
|
131
|
+
webdecoyUrl: 'https://ai-protection.webdecoy.com',
|
|
132
|
+
webdecoyKey: process.env.WEBDECOY_API_KEY!,
|
|
133
|
+
propertyId: process.env.WEBDECOY_PROPERTY_ID!,
|
|
134
|
+
subjectSecret: process.env.WEBDECOY_SUBJECT_SECRET!, // at least 32 bytes
|
|
135
|
+
},
|
|
136
|
+
discovery: { serverId: 'records-api' },
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Use a stable, non-secret server label (1–96 letters, digits, `_`, `.`, `:`, `-`,
|
|
140
|
+
starting with a letter or digit). Reuse it across replicas/releases of the same
|
|
141
|
+
logical server. Give separate servers separate labels within a property. Do not
|
|
142
|
+
include tenant IDs, hostnames containing secrets, or customer information.
|
|
143
|
+
|
|
144
|
+
An authenticated MCP client calls `tools/list`. Each nonempty response queues a
|
|
145
|
+
best-effort advertisement of the visible tool names and SHA-256 input-schema
|
|
146
|
+
hashes. The AI Protection dashboard shows **MCP tool inventory**, including
|
|
147
|
+
tools that have never executed. This does not scan unwrapped servers, hidden tools,
|
|
148
|
+
resources, prompts or alternate routes. No network request is made at handler
|
|
149
|
+
construction. Discovery is off unless configured and requires sharedRuntime.
|
|
150
|
+
|
|
151
|
+
Hashes cover the JSON input schema with recursively sorted object keys; array
|
|
152
|
+
order is preserved. Raw schemas, descriptions, arguments, results, credentials,
|
|
153
|
+
caller identities and required scopes are not uploaded. A schema hash is a
|
|
154
|
+
fingerprint, not encryption; someone with a candidate schema can compare it.
|
|
155
|
+
Multiple hashes for one server/tool show reported variants in the seven-day
|
|
156
|
+
receipt window. Rolling deployments and benign schema edits can cause variants.
|
|
157
|
+
They do not establish an attack, breaking change, removal or missing permission.
|
|
158
|
+
The UI uses the latest receipt's hash, not a claim about deployment order.
|
|
159
|
+
|
|
160
|
+
Discovery uses schema-3 reports on the existing reporting endpoint; deploy a
|
|
161
|
+
compatible hosted runtime first. Old runtimes reject these optional reports
|
|
162
|
+
without affecting tool listing. Reporting has bounded pending work and a deadline,
|
|
163
|
+
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.
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
### Calls before discovery (alpha.8+)
|
|
170
|
+
|
|
171
|
+
With `discovery` enabled, the adapter also attaches the registered server label
|
|
172
|
+
and schema fingerprint to each `tools/call` action report. This includes local
|
|
173
|
+
permission/scope denials for registered tools. It works before a client calls
|
|
174
|
+
`tools/list`; no listing round trip is required to execute an authorized tool.
|
|
175
|
+
Unknown tool names and requests rejected before authentication do not gain
|
|
176
|
+
registry metadata. Turning discovery off omits the new call metadata too.
|
|
177
|
+
|
|
178
|
+
The inventory combines advertisements and action evidence by property, server
|
|
179
|
+
label and tool name. Attempt/start/completion reports sharing an action ID count
|
|
180
|
+
once. Counts include denied attempts, so they are not successful-execution totals.
|
|
181
|
+
“Call evidence only” means no advertisement was captured in the bounded seven-day
|
|
182
|
+
sample. Clients can call directly; hidden scopes, sampling and missing telemetry
|
|
183
|
+
also limit coverage. This is not an attack or permission-gap verdict. Older action
|
|
184
|
+
reports without metadata still appear in the general activity table, aggregated
|
|
185
|
+
by tool name across servers; their server/schema association remains unknown.
|
|
186
|
+
|
|
187
|
+
Deploy a runtime accepting optional `tool_action.tool_schema` before upgrading an
|
|
188
|
+
integration with discovery enabled. Older runtimes reject affected action reports
|
|
189
|
+
without changing admission. The action API also supports optional `toolSchema:
|
|
190
|
+
{serverId, hash}` on trusted registered definitions; it is snapshotted and validated,
|
|
191
|
+
never read from tool arguments. This is SDK-reported metadata, not authorization
|
|
192
|
+
or independent verification of a schema.
|
package/action-runtime.mjs
CHANGED
|
@@ -39,7 +39,7 @@ export function prepareActionRuntime(options, definitions) {
|
|
|
39
39
|
const payload={schema:2,request_id:event.eventId,timestamp:event.timestamp,decision:event.decision,reason:event.reason,
|
|
40
40
|
degraded:event.checks.some(c=>c.decision==='unavailable'),checks,handler_attempted:event.attempted,
|
|
41
41
|
action:event.decision==='deny'?'denied':event.outcome==='unknown'?'handler_error':'forwarded',
|
|
42
|
-
tool_action:{action_id:event.actionId,name:event.action,policy_version:event.policyVersion,outcome:event.outcome,...(event.caller?{caller:event.caller}:{}),...(event.work?{work:event.work}:{})}};
|
|
42
|
+
tool_action:{action_id:event.actionId,name:event.action,policy_version:event.policyVersion,outcome:event.outcome,...(event.toolSchema?{tool_schema:{server_id:event.toolSchema.serverId,hash:event.toolSchema.hash}}:{}),...(event.caller?{caller:event.caller}:{}),...(event.work?{work:event.work}:{})}};
|
|
43
43
|
const response=await fetch(new URL('/api/v1/sdk/ai-abuse/reports',url),{method:'POST',redirect:'error',signal,
|
|
44
44
|
headers:{Authorization:`Bearer ${c.webdecoyKey}`,'X-WebDecoy-Property-ID':c.propertyId,'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
45
45
|
await response.body?.cancel();if(!response.ok)throw Error('Action reporting unavailable');
|
package/actions.d.mts
CHANGED
|
@@ -32,7 +32,10 @@ export interface ActionWork {
|
|
|
32
32
|
/** Confirmed work after completed execution. No async/detached measurement; excess/unknown retains maximum. */
|
|
33
33
|
measure?(result:unknown,context:ActionContext):number;
|
|
34
34
|
}
|
|
35
|
+
/** Server-supplied metadata, not proof of a verified schema or authorization. */
|
|
36
|
+
export interface ToolSchemaEvidence { readonly serverId: string; readonly hash: string; }
|
|
35
37
|
export interface ActionEvent {
|
|
38
|
+
readonly toolSchema?: ToolSchemaEvidence;
|
|
36
39
|
/** Pseudonymous application-authenticated subject, not WebDecoy-verified agent identity. */
|
|
37
40
|
readonly caller?: {readonly schema:1;readonly source:'application_auth';readonly id:string};
|
|
38
41
|
readonly work?:ActionWorkEvidence;
|
|
@@ -64,6 +67,10 @@ export interface ActionRuntime {
|
|
|
64
67
|
reportingTimeoutMs?:number;maxPendingReports?:number;
|
|
65
68
|
}
|
|
66
69
|
export interface ActionDefinition {
|
|
70
|
+
/** Optional non-secret server label and canonical SHA-256 schema hash. MCP discovery fills this automatically.
|
|
71
|
+
* Requires a runtime supporting tool_schema evidence; does not change admission.
|
|
72
|
+
*/
|
|
73
|
+
toolSchema?: ToolSchemaEvidence;
|
|
67
74
|
limits?: ActionLimits;
|
|
68
75
|
requiredScopes: readonly string[];
|
|
69
76
|
validate(args: ActionInput): boolean | Promise<boolean>;
|
package/actions.mjs
CHANGED
|
@@ -72,7 +72,11 @@ export function createActionProtection(options) {
|
|
|
72
72
|
definition.requiredScopes.some(s => !bounded(s)) ||
|
|
73
73
|
['validate','authorize','execute'].some(k => typeof definition[k] !== 'function') ||
|
|
74
74
|
(definition.policy !== undefined && typeof definition.policy !== 'function')) throw Error('Invalid action definition');
|
|
75
|
-
|
|
75
|
+
const t = definition.toolSchema;
|
|
76
|
+
if (t !== undefined && (!t || typeof t.serverId !== 'string' || !token.test(t.serverId) ||
|
|
77
|
+
typeof t.hash !== 'string' || !/^[a-f0-9]{64}$/.test(t.hash))) throw Error('Invalid tool schema evidence');
|
|
78
|
+
const toolSchema = t === undefined ? undefined : Object.freeze({serverId:t.serverId,hash:t.hash});
|
|
79
|
+
actions.set(name, Object.freeze({...definition,toolSchema,requiredScopes:Object.freeze([...definition.requiredScopes])}));
|
|
76
80
|
}
|
|
77
81
|
if (!actions.size || actions.size > 128) throw Error('Expected 1–128 actions');
|
|
78
82
|
const runtime=prepareActionRuntime(options,actions);
|
|
@@ -91,7 +95,7 @@ export function createActionProtection(options) {
|
|
|
91
95
|
function emit(decision, reason, outcome) {
|
|
92
96
|
|
|
93
97
|
const event = Object.freeze({schema:1, eventId:randomUUID(), timestamp:new Date().toISOString(), actionId, action:eventAction, policyVersion,
|
|
94
|
-
...(callerEvidence?{caller:callerEvidence}:{}),evaluation:'local', decision, reason, attempted, outcome,...(work?{work:Object.freeze({...work.evidence})}:{}),checks:Object.freeze(checks.map(c=>Object.freeze({...c})))});
|
|
98
|
+
...(callerEvidence?{caller:callerEvidence}:{}),...(action?.toolSchema?{toolSchema:action.toolSchema}:{}),evaluation:'local', decision, reason, attempted, outcome,...(work?{work:Object.freeze({...work.evidence})}:{}),checks:Object.freeze(checks.map(c=>Object.freeze({...c})))});
|
|
95
99
|
if(runtime)void runtime.report(event);
|
|
96
100
|
if(!sink||pendingEvents>=100)return;
|
|
97
101
|
pendingEvents++;
|
package/mcp.d.mts
CHANGED
|
@@ -4,7 +4,7 @@ import type {CallToolResult, Tool} from '@modelcontextprotocol/sdk/types.js';
|
|
|
4
4
|
import type {ActionContext, ActionDefinition, ActionEvent, ActionRuntime, TrustedCaller} from './actions.mjs';
|
|
5
5
|
|
|
6
6
|
/** Explicitly registered tool; inputSchema describes discovery, validate enforces input. */
|
|
7
|
-
export interface ProtectedTool extends ActionDefinition {
|
|
7
|
+
export interface ProtectedTool extends Omit<ActionDefinition, 'toolSchema'> {
|
|
8
8
|
description: string;
|
|
9
9
|
inputSchema: Tool['inputSchema'];
|
|
10
10
|
/** Await all protected work and return a complete MCP result, never a detached stream. */
|
|
@@ -21,6 +21,10 @@ export interface ProtectedMCPOptions {
|
|
|
21
21
|
policyVersion: string;
|
|
22
22
|
sharedRuntime?: ActionRuntime;
|
|
23
23
|
tools: Record<string, ProtectedTool>;
|
|
24
|
+
/** Opt-in tools/list and tools/call metadata: stable non-secret server label and SHA-256 input-schema hashes.
|
|
25
|
+
* Requires sharedRuntime. Reuse serverId across replicas; separate different servers.
|
|
26
|
+
*/
|
|
27
|
+
discovery?: { serverId: string };
|
|
24
28
|
/** Exact browser origins; requests without Origin are permitted after authentication. */
|
|
25
29
|
allowedOrigins?: string[];
|
|
26
30
|
/** Best-effort, sanitized action events. */
|
|
@@ -33,4 +37,7 @@ export interface ProtectedMCPOptions {
|
|
|
33
37
|
* Does not wrap existing MCP servers, resources, prompts, tasks or alternate routes.
|
|
34
38
|
*/
|
|
35
39
|
export function createProtectedMCPHandler(options: ProtectedMCPOptions):
|
|
36
|
-
(request: IncomingMessage, response: ServerResponse) => Promise<void
|
|
40
|
+
((request: IncomingMessage, response: ServerResponse) => Promise<void>) & {
|
|
41
|
+
/** Drain pending discovery reports at shutdown. Does not wait for running tools. */
|
|
42
|
+
flush(): Promise<void>;
|
|
43
|
+
};
|
package/mcp.mjs
CHANGED
|
@@ -2,6 +2,8 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
2
2
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
4
4
|
import { createActionProtection, ActionDenied } from './actions.mjs';
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
+
import { createReporter } from './reporting.mjs';
|
|
5
7
|
const metadataPath = '/.well-known/oauth-protected-resource/mcp';
|
|
6
8
|
export function createProtectedMCPHandler(options) {
|
|
7
9
|
const resource = new URL(options.resource), issuer = new URL(options.authorizationServer);
|
|
@@ -9,19 +11,49 @@ export function createProtectedMCPHandler(options) {
|
|
|
9
11
|
if ((resource.protocol !== 'https:' && !(loopback && resource.protocol === 'http:')) || resource.pathname !== '/mcp' || resource.search || resource.hash || resource.username || resource.password || issuer.protocol !== 'https:' || issuer.search || issuer.hash || issuer.username || issuer.password)
|
|
10
12
|
throw Error('Invalid MCP resource configuration');
|
|
11
13
|
const metadataURL = new URL(metadataPath, resource).href;
|
|
12
|
-
const tools = Object.fromEntries(Object.entries(options.tools).map(([name, t]) => [name, { ...t, requiredScopes: [...t.requiredScopes], inputSchema: structuredClone(t.inputSchema) }]));
|
|
14
|
+
const tools = Object.fromEntries(Object.entries(options.tools).map(([name, t]) => [name, { ...t, toolSchema: undefined, requiredScopes: [...t.requiredScopes], inputSchema: structuredClone(t.inputSchema) }]));
|
|
13
15
|
// Validate the closed registry at startup, not only after a client arrives.
|
|
14
16
|
createActionProtection({ policyVersion: options.policyVersion, authenticate: options.authenticate, actions: tools, sharedRuntime: options.sharedRuntime });
|
|
15
17
|
for (const tool of Object.values(tools))
|
|
16
18
|
for (const scope of tool.requiredScopes)
|
|
17
19
|
if (!/^[\x21\x23-\x5b\x5d-\x7e]+$/.test(scope))
|
|
18
20
|
throw Error('Invalid OAuth scope');
|
|
21
|
+
// Opt-in metadata only. Never collect descriptions, raw schemas, scopes,
|
|
22
|
+
// credentials, arguments or resource URLs in discovery reports.
|
|
23
|
+
if (options.discovery !== undefined && (!options.sharedRuntime || !options.discovery ||
|
|
24
|
+
!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,95}$/.test(options.discovery.serverId)))
|
|
25
|
+
throw Error('Discovery requires sharedRuntime and a stable serverId');
|
|
26
|
+
const canonical = value => {
|
|
27
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
28
|
+
if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
|
|
29
|
+
return '{' + Object.keys(value).sort().map(key => JSON.stringify(key) + ':' + canonical(value[key])).join(',') + '}';
|
|
30
|
+
};
|
|
31
|
+
const hashes = options.discovery ? Object.fromEntries(Object.entries(tools).map(([name, tool]) =>
|
|
32
|
+
[name, createHash('sha256').update(canonical(JSON.parse(JSON.stringify(tool.inputSchema)))).digest('hex')])) : {};
|
|
33
|
+
if (options.discovery) for (const [name, tool] of Object.entries(tools))
|
|
34
|
+
tool.toolSchema = Object.freeze({serverId:options.discovery.serverId,hash:hashes[name]});
|
|
35
|
+
const runtime = options.sharedRuntime && { ...options.sharedRuntime };
|
|
36
|
+
const serverId = options.discovery?.serverId;
|
|
37
|
+
const catalogReporter = options.discovery ? createReporter({
|
|
38
|
+
reportingTimeoutMs: runtime.reportingTimeoutMs ?? 1000,
|
|
39
|
+
maxPendingReports: runtime.maxPendingReports ?? 100,
|
|
40
|
+
onObservation: async (names, { signal }) => {
|
|
41
|
+
const response = await fetch(new URL('/api/v1/sdk/ai-abuse/reports', runtime.webdecoyUrl), {
|
|
42
|
+
method: 'POST', redirect: 'error', signal,
|
|
43
|
+
headers: { Authorization: `Bearer ${runtime.webdecoyKey}`, 'X-WebDecoy-Property-ID': runtime.propertyId, 'Content-Type': 'application/json' },
|
|
44
|
+
body: JSON.stringify({ schema: 3, request_id: randomUUID(), timestamp: new Date().toISOString(), action: 'tool_discovery',
|
|
45
|
+
tool_catalog: { server_id: serverId, source: 'tools_list', tools: names.map(name => ({ name, schema_hash: hashes[name] })) } })
|
|
46
|
+
});
|
|
47
|
+
await response.body?.cancel();
|
|
48
|
+
if (!response.ok) throw Error('Discovery reporting unavailable');
|
|
49
|
+
}
|
|
50
|
+
}) : null;
|
|
19
51
|
const active = new Map();
|
|
20
52
|
const scopes = [...new Set(Object.values(tools).flatMap(t => t.requiredScopes))];
|
|
21
53
|
const origins = new Set(options.allowedOrigins ?? []);
|
|
22
54
|
function respond(res, status, error, headers = {}) { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...headers }); res.end(JSON.stringify({ error })); }
|
|
23
55
|
function challenge(res, status, scope) { respond(res, status, status === 401 ? 'unauthorized' : 'insufficient_scope', { 'WWW-Authenticate': `Bearer resource_metadata="${metadataURL}"${status === 401 ? '' : `, error="insufficient_scope", scope="${scope.join(' ')}"`}` }); }
|
|
24
|
-
|
|
56
|
+
const handle = async function (req, res) {
|
|
25
57
|
const disconnected = new AbortController();
|
|
26
58
|
req.once('aborted', () => disconnected.abort());
|
|
27
59
|
res.once('close', () => { if (!res.writableEnded)
|
|
@@ -163,9 +195,11 @@ export function createProtectedMCPHandler(options) {
|
|
|
163
195
|
const server = new Server({ name: 'webdecoy-protected-tools', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
164
196
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: false });
|
|
165
197
|
res.once('close', () => { void server.close().catch(() => { }); });
|
|
166
|
-
server.setRequestHandler(ListToolsRequestSchema, async () =>
|
|
167
|
-
|
|
168
|
-
|
|
198
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
199
|
+
const visible = Object.entries(tools).filter(([, tool]) => tool.requiredScopes.every(s => caller.scopes.includes(s)));
|
|
200
|
+
if (visible.length) void catalogReporter?.send(visible.map(([name]) => name));
|
|
201
|
+
return { tools: visible.map(([name, tool]) => ({ name, description: tool.description, inputSchema: tool.inputSchema })) };
|
|
202
|
+
});
|
|
169
203
|
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
170
204
|
if (!Object.hasOwn(tools, request.params.name))
|
|
171
205
|
throw new McpError(ErrorCode.InvalidParams, 'Tool unavailable');
|
|
@@ -197,4 +231,5 @@ export function createProtectedMCPHandler(options) {
|
|
|
197
231
|
res.destroy();
|
|
198
232
|
}
|
|
199
233
|
};
|
|
234
|
+
return Object.assign(handle, { flush: async () => { await catalogReporter?.flush(); } });
|
|
200
235
|
}
|
package/package.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"check:package": "node scripts/check-package.mjs",
|
|
11
11
|
"test:types": "tsc --strict --noEmit --module nodenext --target es2022 test/types.mts test/mcp-types.mts"
|
|
12
12
|
},
|
|
13
|
-
"version": "0.1.0-alpha.
|
|
13
|
+
"version": "0.1.0-alpha.8",
|
|
14
14
|
"exports": {
|
|
15
15
|
"./fetch": {
|
|
16
16
|
"types": "./fetch.d.mts",
|