@webdecoy/ai-protection 0.1.0-alpha.2 → 0.1.0-alpha.4
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 +119 -0
- package/README.md +12 -1
- package/action-runtime.mjs +41 -0
- package/actions.d.mts +70 -0
- package/actions.mjs +168 -0
- package/mcp.d.mts +36 -0
- package/mcp.mjs +200 -0
- package/package.json +28 -4
package/MCP.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# MCP tool protection (Alpha)
|
|
2
|
+
|
|
3
|
+
`@webdecoy/ai-protection/mcp` is a Node HTTP handler for a closed registry of
|
|
4
|
+
protected tools. It runs in your application's backend. Your MCP traffic stays
|
|
5
|
+
on your server; configured shared limits and sanitized action reports use the
|
|
6
|
+
WebDecoy runtime. Your application owns authentication, tenant membership and
|
|
7
|
+
resource authorization.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
Available in `0.1.0-alpha.4` and later compatible alpha releases:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install @webdecoy/ai-protection@0.1.0-alpha.4 @modelcontextprotocol/sdk@1.31.0
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Node 22.22.3+ and MCP SDK **1.31.0**. The MCP SDK is an optional peer, so
|
|
18
|
+
ordinary request/action SDK installs do not install it. Only `/mcp` imports it.
|
|
19
|
+
TypeScript Node applications also need their usual `@types/node` development
|
|
20
|
+
dependency. Tests exercise MCP protocol **2025-11-25** with the official client.
|
|
21
|
+
|
|
22
|
+
## Connect your application
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import {createServer} from 'node:http';
|
|
26
|
+
import {createProtectedMCPHandler} from '@webdecoy/ai-protection/mcp';
|
|
27
|
+
import {verifyAccessToken, records} from './your-application.js';
|
|
28
|
+
|
|
29
|
+
const handler = createProtectedMCPHandler({
|
|
30
|
+
resource: 'https://api.example.com/mcp',
|
|
31
|
+
authorizationServer: 'https://your-issuer.example.com/',
|
|
32
|
+
policyVersion: 'records_v1',
|
|
33
|
+
// Your verifier validates issuer, audience/resource, signature and expiry,
|
|
34
|
+
// then resolves tenant membership from trusted application state.
|
|
35
|
+
authenticate: (request, {signal}) => verifyAccessToken(request, {signal}),
|
|
36
|
+
tools: {
|
|
37
|
+
'records.read': {
|
|
38
|
+
description: 'Read an owned record',
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: 'object', properties: {id: {type: 'string'}},
|
|
41
|
+
required: ['id'], additionalProperties: false,
|
|
42
|
+
},
|
|
43
|
+
requiredScopes: ['records:read'],
|
|
44
|
+
validate: args => args !== null && typeof args === 'object'
|
|
45
|
+
&& !Array.isArray(args) && Object.keys(args).length === 1
|
|
46
|
+
&& 'id' in args && typeof args.id === 'string',
|
|
47
|
+
authorize: ({caller, args}) => records.canRead(caller, args),
|
|
48
|
+
// The database query must scope by the trusted tenant, even after authorize.
|
|
49
|
+
// Await all work; return a complete MCP result, not a detached stream/task.
|
|
50
|
+
execute: ({caller, args, signal}) => records.readForTenant(caller.tenant, args, signal),
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const server = createServer((req, res) => { void handler(req, res); });
|
|
55
|
+
server.requestTimeout = 10000;
|
|
56
|
+
server.headersTimeout = 10000;
|
|
57
|
+
server.listen(8093, '127.0.0.1'); // Put your existing HTTPS proxy in front.
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The application imports above are integration hooks, not exports from this SDK.
|
|
61
|
+
See the [runnable Auth0-backed example](examples/mcp/README.md) for a concrete
|
|
62
|
+
verifier and test registry. The verifier returns the [trusted caller contract](actions.d.mts):
|
|
63
|
+
schema, subject, tenant, issuer, authentication method, expiry and scopes. Never
|
|
64
|
+
derive tenant or permissions from tool arguments, a session ID, agent name or a
|
|
65
|
+
WebDecoy API key. A separate WebDecoy-owned Auth0 account is not required.
|
|
66
|
+
|
|
67
|
+
`inputSchema` advertises the tool shape; the application `validate` hook enforces
|
|
68
|
+
it before dispatch. Tool listing is filtered by scopes, while every tool call
|
|
69
|
+
independently checks scopes, validation and application permissions. Do not expose
|
|
70
|
+
the same operation through an unguarded alternate handler.
|
|
71
|
+
|
|
72
|
+
## Shared controls and evidence
|
|
73
|
+
|
|
74
|
+
Supply `sharedRuntime` with the server-only WebDecoy URL, API key, property ID and
|
|
75
|
+
stable subject secret. Add `limits.callerQuota`, `limits.tenantQuota` and/or
|
|
76
|
+
`limits.concurrency` to each protected tool. These use the same configuration as
|
|
77
|
+
the [action API](examples/actions/README.md#shared-limits-and-hosted-evidence).
|
|
78
|
+
Limits follow verified identities, not MCP connection/session identifiers.
|
|
79
|
+
|
|
80
|
+
Authentication and explicit permission denials always stop dispatch. Shared-state
|
|
81
|
+
failure follows each limit's configured `failureMode`; use enforce/closed for a
|
|
82
|
+
hard admission limit. Observe/open does not establish a hard ceiling. This adapter
|
|
83
|
+
does not itself run request bot detection or a prompt scanner. If you add detector
|
|
84
|
+
checks elsewhere, keep their default fail-open behavior separate from permissions.
|
|
85
|
+
|
|
86
|
+
Optional `onEvent` receives sanitized action outcomes. Shared-runtime reporting is
|
|
87
|
+
best effort. Events omit tokens, raw identities, tool arguments and result content.
|
|
88
|
+
A completed event means the callback resolved; independently check your own
|
|
89
|
+
database/provider for side-effect confirmation. A failed/disconnected call may
|
|
90
|
+
have unknown work. Never retry writes without application/provider idempotency.
|
|
91
|
+
|
|
92
|
+
## Supported boundary
|
|
93
|
+
|
|
94
|
+
- Stateless Streamable HTTP at `/mcp`: initialize, ping, scoped `tools/list`,
|
|
95
|
+
protected `tools/call`, and caller-bound cancellation notifications.
|
|
96
|
+
- Protected-resource metadata at `/.well-known/oauth-protected-resource/mcp`;
|
|
97
|
+
missing/invalid credentials get HTTP 401, missing scopes get HTTP 403 challenges.
|
|
98
|
+
Permission/limit denials after admission use MCP tool errors with bounded retry
|
|
99
|
+
guidance in `_meta["webdecoy.com/action-error"]`.
|
|
100
|
+
- Host must match `resource`; present Origin headers must be explicitly allowed.
|
|
101
|
+
Non-browser clients can omit Origin. Preserve Host through your trusted proxy.
|
|
102
|
+
- At most 16 KiB per JSON message, five-second body read, one-second authentication
|
|
103
|
+
deadline and at most 128 active tool calls per handler. These local bounds are
|
|
104
|
+
distinct from shared caller quotas.
|
|
105
|
+
- SSE carries the final MCP response. Tools must await completion; arbitrary live
|
|
106
|
+
tool-result streams are unsupported. A disconnect requests cooperative abort;
|
|
107
|
+
explicit cancellation is scoped to the authenticated caller/client/request ID.
|
|
108
|
+
Uncooperative work retains its local active slot until it settles.
|
|
109
|
+
- No session store, replay/resumption, automatic retry or exactly-once execution.
|
|
110
|
+
Supplied session/replay IDs are rejected; standalone SSE GET and DELETE return
|
|
111
|
+
405. Reconnects do not grant fresh identity allowances.
|
|
112
|
+
- Resources, prompts, tasks, sampling, elicitation, other routes and pre-existing
|
|
113
|
+
MCP handlers are not wrapped. Unregistered methods/tools do not dispatch.
|
|
114
|
+
Weighted operation budgets and complete cross-replica lifecycle acceptance
|
|
115
|
+
remain separate work.
|
|
116
|
+
|
|
117
|
+
This is an explicit tools integration, not transparent protection of an existing
|
|
118
|
+
whole MCP server. See the [MCP transport specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports)
|
|
119
|
+
and [authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Bot and abuse protection for AI-powered applications. A small Node.js SDK that
|
|
|
4
4
|
checks requests before your application invokes a model. Customer-defined rules run
|
|
5
5
|
locally; proprietary bot detection runs in WebDecoy. See [architecture](ARCHITECTURE.md).
|
|
6
6
|
|
|
7
|
-
**Alpha release: `0.1.0-alpha.
|
|
7
|
+
**Alpha release: `0.1.0-alpha.3`.** Integration mechanics are tested;
|
|
8
8
|
real-world detection accuracy and provider cost savings have not been established.
|
|
9
9
|
Requires a WebDecoy property, a property-scoped API key, and a compatible WebDecoy
|
|
10
10
|
service deployment. This repository contains the SDK, not the detection service.
|
|
@@ -317,3 +317,14 @@ snapshot, not current quota state.
|
|
|
317
317
|
|
|
318
318
|
Only admission is deduplicated. Repeated application/model calls still require
|
|
319
319
|
application-level idempotency. The hosted runtime must support quota schema 2 before enabling this option.
|
|
320
|
+
|
|
321
|
+
## Action authorization (Alpha)
|
|
322
|
+
|
|
323
|
+
Version `0.1.0-alpha.3` includes an action boundary at `@webdecoy/ai-protection/actions`. See the
|
|
324
|
+
[record-action example and integration contract](examples/actions/README.md).
|
|
325
|
+
This API requires your server authentication and application authorization.
|
|
326
|
+
An [Auth0 access-token example](examples/auth0/README.md) verifies signed tokens
|
|
327
|
+
and maps tenant membership. The [MCP adapter](MCP.md) provides a separate `/mcp` entrypoint for stateless
|
|
328
|
+
HTTP tool dispatch starting in `0.1.0-alpha.4`.
|
|
329
|
+
It requires the optional, pinned MCP SDK peer. Optional shared quotas, concurrency and hosted
|
|
330
|
+
action events are documented in the action guide and use the hosted AI Protection runtime and dashboard.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {prepareQuota,quotaHash} from './quota.mjs';
|
|
2
|
+
import {prepareConcurrency} from './concurrency.mjs';
|
|
3
|
+
import {validPropertyID} from './account.mjs';
|
|
4
|
+
import {createReporter} from './reporting.mjs';
|
|
5
|
+
|
|
6
|
+
export function prepareActionRuntime(options, definitions) {
|
|
7
|
+
const config=options.sharedRuntime;
|
|
8
|
+
if(!config){if([...definitions.values()].some(d=>d.limits))throw Error('Action limits require sharedRuntime');return null;}
|
|
9
|
+
const url=new URL(config.webdecoyUrl);
|
|
10
|
+
if(!['https:','http:'].includes(url.protocol)||url.username||url.password||url.pathname!=='/'||url.search||url.hash||
|
|
11
|
+
(url.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(url.hostname))||!validPropertyID(config.propertyId)||
|
|
12
|
+
typeof config.webdecoyKey!=='string'||!config.webdecoyKey||/[^\x21-\x7e]/.test(config.webdecoyKey)||
|
|
13
|
+
typeof config.subjectSecret!=='string'||Buffer.byteLength(config.subjectSecret)<32)throw Error('Invalid action runtime');
|
|
14
|
+
const c={...config};const limits=new Map(),ruleIDs=new Set();
|
|
15
|
+
const subject=(ctx,tenant)=>({accountId:tenant?quotaHash(c.subjectSecret,'webdecoy.actions.tenant.v1',ctx.caller.tenant):quotaHash(c.subjectSecret,'webdecoy.actions.caller.v1',ctx.caller.issuer,ctx.caller.tenant,ctx.caller.subject)});
|
|
16
|
+
for(const [name,d] of definitions){
|
|
17
|
+
const l=d.limits??{},gates=[];
|
|
18
|
+
for(const [key,tenant]of [['callerQuota',false],['tenantQuota',true]])if(l[key]){
|
|
19
|
+
const q=l[key];if(ruleIDs.has(q.ruleId))throw Error('Action limits require distinct rule IDs');ruleIDs.add(q.ruleId);
|
|
20
|
+
const gate=prepareQuota({...c,accountQuota:{...q,idempotency:false,operationId:undefined,sessionLimit:0,subject:ctx=>subject(ctx,tenant)}});
|
|
21
|
+
gates.push(async(ctx,signal)=>{const r=await gate(ctx,signal);if(r.check)r.check.id=tenant?'tenant_quota':'caller_quota';return r;});
|
|
22
|
+
}
|
|
23
|
+
let concurrency=null;
|
|
24
|
+
if(l.concurrency){if(ruleIDs.has(l.concurrency.ruleId))throw Error('Action limits require distinct rule IDs');ruleIDs.add(l.concurrency.ruleId);
|
|
25
|
+
concurrency=prepareConcurrency({...c,concurrency:{...l.concurrency,subject:ctx=>subject(ctx,false)}});}
|
|
26
|
+
limits.set(name,{gates,concurrency});
|
|
27
|
+
}
|
|
28
|
+
const reporter=createReporter({reportingTimeoutMs:c.reportingTimeoutMs??1000,maxPendingReports:c.maxPendingReports??100,
|
|
29
|
+
onObservation:async(event,{signal})=>{
|
|
30
|
+
const checks=[{id:'action_boundary',source:'local',mode:'enforce',decision:event.decision,reason:event.reason,duration_ms:0},
|
|
31
|
+
...event.checks.map(check=>({id:check.id,source:check.source,mode:check.mode,decision:check.decision,reason:check.reason,duration_ms:check.durationMs}))];
|
|
32
|
+
const payload={schema:2,request_id:event.eventId,timestamp:event.timestamp,decision:event.decision,reason:event.reason,
|
|
33
|
+
degraded:event.checks.some(c=>c.decision==='unavailable'),checks,handler_attempted:event.attempted,
|
|
34
|
+
action:event.decision==='deny'?'denied':event.outcome==='unknown'?'handler_error':'forwarded',
|
|
35
|
+
tool_action:{action_id:event.actionId,name:event.action,policy_version:event.policyVersion,outcome:event.outcome}};
|
|
36
|
+
const response=await fetch(new URL('/api/v1/sdk/ai-abuse/reports',url),{method:'POST',redirect:'error',signal,
|
|
37
|
+
headers:{Authorization:`Bearer ${c.webdecoyKey}`,'X-WebDecoy-Property-ID':c.propertyId,'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
38
|
+
await response.body?.cancel();if(!response.ok)throw Error('Action reporting unavailable');
|
|
39
|
+
}});
|
|
40
|
+
return {limits,report:event=>reporter.send(event),flush:()=>reporter.flush()};
|
|
41
|
+
}
|
package/actions.d.mts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Contract supplied by verified server authentication, never parsed from tool arguments. */
|
|
2
|
+
export interface TrustedCaller {
|
|
3
|
+
readonly schema: 1;
|
|
4
|
+
readonly subject: string;
|
|
5
|
+
readonly tenant: string;
|
|
6
|
+
readonly issuer: string;
|
|
7
|
+
readonly authenticationMethod: string;
|
|
8
|
+
/** Unix milliseconds; checked again immediately before execution. */
|
|
9
|
+
readonly expiresAt: number;
|
|
10
|
+
readonly scopes: readonly string[];
|
|
11
|
+
/** OAuth client identity, separate from subject; not an agent signer. */
|
|
12
|
+
readonly clientId?: string;
|
|
13
|
+
}
|
|
14
|
+
export type ActionInput = null | boolean | number | string | readonly ActionInput[] | {readonly [key:string]: ActionInput};
|
|
15
|
+
export interface ActionContext {
|
|
16
|
+
readonly caller: TrustedCaller;
|
|
17
|
+
readonly args: ActionInput;
|
|
18
|
+
readonly signal?: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
export interface ActionEvent {
|
|
21
|
+
readonly schema: 1;
|
|
22
|
+
readonly eventId: string;
|
|
23
|
+
readonly timestamp: string;
|
|
24
|
+
readonly checks: readonly {id:string;source:string;mode:string;decision:string;reason:string;durationMs:number}[];
|
|
25
|
+
readonly actionId: string;
|
|
26
|
+
readonly action: string;
|
|
27
|
+
readonly policyVersion: string;
|
|
28
|
+
readonly evaluation: 'local';
|
|
29
|
+
readonly decision: 'allow' | 'deny';
|
|
30
|
+
readonly reason: string;
|
|
31
|
+
readonly attempted: boolean;
|
|
32
|
+
readonly outcome: 'not_attempted' | 'attempted' | 'completed' | 'unknown';
|
|
33
|
+
}
|
|
34
|
+
export interface ActionQuota {ruleId:string;limit:number;windowSeconds:number;mode?:'observe'|'enforce';failureMode?:'open'|'closed';timeoutMs?:number}
|
|
35
|
+
export interface ActionLimits {
|
|
36
|
+
callerQuota?: ActionQuota;
|
|
37
|
+
tenantQuota?: ActionQuota;
|
|
38
|
+
concurrency?: {ruleId:string;accountLimit:number;featureLimit:number;mode?:'observe'|'enforce';failureMode?:'open'|'closed';ttlSeconds?:number;maxSeconds?:number;timeoutMs?:number};
|
|
39
|
+
}
|
|
40
|
+
export interface ActionRuntime {
|
|
41
|
+
webdecoyUrl:string;webdecoyKey:string;propertyId:string;subjectSecret:string;
|
|
42
|
+
reportingTimeoutMs?:number;maxPendingReports?:number;
|
|
43
|
+
}
|
|
44
|
+
export interface ActionDefinition {
|
|
45
|
+
limits?: ActionLimits;
|
|
46
|
+
requiredScopes: readonly string[];
|
|
47
|
+
validate(args: ActionInput): boolean | Promise<boolean>;
|
|
48
|
+
authorize(context: ActionContext): boolean | Promise<boolean>;
|
|
49
|
+
/** Additional restrictive policy; cannot override application authorization. */
|
|
50
|
+
policy?(context: ActionContext): boolean | Promise<boolean>;
|
|
51
|
+
/** Await all protected work. Do not return a live stream or detached task. */
|
|
52
|
+
execute(context: ActionContext): unknown | Promise<unknown>;
|
|
53
|
+
}
|
|
54
|
+
export class ActionDenied extends Error {
|
|
55
|
+
readonly reason: string;
|
|
56
|
+
readonly status: number;
|
|
57
|
+
readonly actionId: string;
|
|
58
|
+
readonly retryAfterSeconds?: number;
|
|
59
|
+
constructor(reason: string, status: number, actionId: string);
|
|
60
|
+
}
|
|
61
|
+
export function createActionProtection<T>(options: {
|
|
62
|
+
policyVersion: string;
|
|
63
|
+
sharedRuntime?: ActionRuntime;
|
|
64
|
+
/** Bounds local admission checks; shared controls have their own RPC deadlines. Default 1000 ms, max 10000. */
|
|
65
|
+
admissionTimeoutMs?: number;
|
|
66
|
+
authenticate(context: T, options: {signal?: AbortSignal}): TrustedCaller | Promise<TrustedCaller>;
|
|
67
|
+
actions: Record<string, ActionDefinition>;
|
|
68
|
+
/** Local best-effort observer. sharedRuntime enables independent hosted reporting. */
|
|
69
|
+
onEvent?(event: ActionEvent): void | Promise<void>;
|
|
70
|
+
}): {flush():Promise<void>;run(action: string, args: ActionInput, authenticationContext: T, options?: {signal?: AbortSignal}): Promise<unknown>};
|
package/actions.mjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
|
+
import {abortable} from './transport.mjs';
|
|
3
|
+
import {prepareActionRuntime} from './action-runtime.mjs';
|
|
4
|
+
|
|
5
|
+
const token = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,95}$/;
|
|
6
|
+
const bounded = value => typeof value === 'string' && value.length > 0 && value.length <= 512 && !/[\x00-\x1f\x7f]/.test(value);
|
|
7
|
+
const freeze = value => {
|
|
8
|
+
if (value && typeof value === 'object') { for (const child of Object.values(value)) freeze(child); Object.freeze(value); }
|
|
9
|
+
return value;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export class ActionDenied extends Error {
|
|
13
|
+
constructor(reason, status, actionId) {
|
|
14
|
+
super(reason); this.name = 'ActionDenied'; this.reason = reason; this.status = status; this.actionId = actionId;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Authentication is owned by the application's server integration. This validates
|
|
19
|
+
// its contract; it does not verify a bearer token, signer, or model-supplied claim.
|
|
20
|
+
function callerSnapshot(value) {
|
|
21
|
+
if (!value || value.schema !== 1 || !bounded(value.subject) || !bounded(value.tenant) ||
|
|
22
|
+
!bounded(value.issuer) || !bounded(value.authenticationMethod) ||
|
|
23
|
+
!Number.isSafeInteger(value.expiresAt) || value.expiresAt <= Date.now() ||
|
|
24
|
+
!Array.isArray(value.scopes) || value.scopes.length > 64 || value.scopes.some(s => !bounded(s)) ||
|
|
25
|
+
(value.clientId !== undefined && !bounded(value.clientId))) throw Error('Invalid authenticated caller');
|
|
26
|
+
return freeze({schema:1, subject:value.subject, tenant:value.tenant, issuer:value.issuer,
|
|
27
|
+
authenticationMethod:value.authenticationMethod, expiresAt:value.expiresAt,
|
|
28
|
+
scopes:[...new Set(value.scopes)], ...(value.clientId === undefined ? {} : {clientId:value.clientId})});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// No accessors, class instances, cycles, non-finite numbers, or unbounded trees.
|
|
32
|
+
// Copy before any await so a caller cannot swap arguments while policy runs.
|
|
33
|
+
function inputSnapshot(input) {
|
|
34
|
+
let nodes = 0;
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
function copy(value, depth) {
|
|
37
|
+
if (++nodes > 2048 || depth > 12) throw Error('Input too complex');
|
|
38
|
+
if (value === null || typeof value === 'boolean') return value;
|
|
39
|
+
if (typeof value === 'string') { if (value.length > 16384) throw Error('Input too large'); return value; }
|
|
40
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
41
|
+
if (!value || typeof value !== 'object' || seen.has(value) ||
|
|
42
|
+
(!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) throw Error('Invalid JSON input');
|
|
43
|
+
if (Array.isArray(value) && value.length > 2048) throw Error('Input array too large');
|
|
44
|
+
const keys = Object.keys(value);
|
|
45
|
+
if (keys.length > 2048) throw Error('Input too complex');
|
|
46
|
+
seen.add(value);
|
|
47
|
+
const out = Array.isArray(value) ? [] : Object.create(null);
|
|
48
|
+
for (const key of keys) {
|
|
49
|
+
if (Array.isArray(value) && !/^(0|[1-9][0-9]*)$/.test(key)) throw Error('Invalid array key');
|
|
50
|
+
const d = Object.getOwnPropertyDescriptor(value, key);
|
|
51
|
+
if (!d || !('value' in d) || key.length > 512 || ['__proto__','constructor','prototype'].includes(key)) throw Error('Invalid input key');
|
|
52
|
+
out[key] = copy(d.value, depth + 1);
|
|
53
|
+
}
|
|
54
|
+
seen.delete(value);
|
|
55
|
+
return Object.freeze(out);
|
|
56
|
+
}
|
|
57
|
+
const result = copy(input, 0);
|
|
58
|
+
if (Buffer.byteLength(JSON.stringify(result)) > 16384) throw Error('Input too large');
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Local protected dispatch. No network calls, cloud override, or automatic retry. */
|
|
63
|
+
export function createActionProtection(options) {
|
|
64
|
+
if (!options || typeof options.authenticate !== 'function' || (typeof options.policyVersion !== 'string' || !token.test(options.policyVersion))) throw Error('Invalid action configuration');
|
|
65
|
+
const authenticate = options.authenticate, policyVersion = options.policyVersion, sink = options.onEvent;
|
|
66
|
+
if (sink !== undefined && typeof sink !== 'function') throw Error('Invalid event sink');
|
|
67
|
+
const admissionTimeoutMs = options.admissionTimeoutMs ?? 1000;
|
|
68
|
+
if (!Number.isInteger(admissionTimeoutMs) || admissionTimeoutMs < 1 || admissionTimeoutMs > 10000) throw Error('Invalid admission timeout');
|
|
69
|
+
const actions = new Map();
|
|
70
|
+
for (const [name, definition] of Object.entries(options.actions ?? {})) {
|
|
71
|
+
if (!token.test(name) || !definition || !Array.isArray(definition.requiredScopes) || definition.requiredScopes.length > 64 ||
|
|
72
|
+
definition.requiredScopes.some(s => !bounded(s)) ||
|
|
73
|
+
['validate','authorize','execute'].some(k => typeof definition[k] !== 'function') ||
|
|
74
|
+
(definition.policy !== undefined && typeof definition.policy !== 'function')) throw Error('Invalid action definition');
|
|
75
|
+
actions.set(name, Object.freeze({...definition,requiredScopes:Object.freeze([...definition.requiredScopes])}));
|
|
76
|
+
}
|
|
77
|
+
if (!actions.size || actions.size > 128) throw Error('Expected 1–128 actions');
|
|
78
|
+
const runtime=prepareActionRuntime(options,actions);
|
|
79
|
+
let pendingEvents = 0;
|
|
80
|
+
return Object.freeze({flush:async()=>{await runtime?.flush();},async run(name, input, authenticationContext, {signal} = {}) {
|
|
81
|
+
const actionId = randomUUID();
|
|
82
|
+
// Unknown caller-controlled action strings are never placed in evidence.
|
|
83
|
+
const action = actions.get(name), eventAction = action ? name : 'unregistered';
|
|
84
|
+
let attempted = false,completed=false,lease;
|
|
85
|
+
const checks=[];
|
|
86
|
+
const deadline = new AbortController();
|
|
87
|
+
const admissionSignal = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal;
|
|
88
|
+
const timer = setTimeout(() => deadline.abort(new DOMException('Action admission timed out','TimeoutError')), admissionTimeoutMs);
|
|
89
|
+
const evaluate = fn => abortable(Promise.resolve().then(() => { admissionSignal.throwIfAborted(); return fn(); }), admissionSignal);
|
|
90
|
+
function emit(decision, reason, outcome) {
|
|
91
|
+
|
|
92
|
+
const event = Object.freeze({schema:1, eventId:randomUUID(), timestamp:new Date().toISOString(), actionId, action:eventAction, policyVersion,
|
|
93
|
+
evaluation:'local', decision, reason, attempted, outcome,checks:Object.freeze(checks.map(c=>Object.freeze({...c})))});
|
|
94
|
+
if(runtime)void runtime.report(event);
|
|
95
|
+
if(!sink||pendingEvents>=100)return;
|
|
96
|
+
pendingEvents++;
|
|
97
|
+
Promise.resolve().then(() => sink(event)).catch(() => {}).finally(() => { pendingEvents--; });
|
|
98
|
+
}
|
|
99
|
+
function deny(reason, status, retryAfterSeconds) { emit('deny', reason, 'not_attempted'); const e=new ActionDenied(reason,status,actionId);if(retryAfterSeconds)e.retryAfterSeconds=retryAfterSeconds;throw e; }
|
|
100
|
+
function cancelled() { (attempted ? signal : admissionSignal)?.throwIfAborted(); }
|
|
101
|
+
try {
|
|
102
|
+
cancelled();
|
|
103
|
+
if (!action) deny('action_not_registered',403);
|
|
104
|
+
let args;
|
|
105
|
+
try { args = inputSnapshot(input); } catch { deny('invalid_arguments',400); }
|
|
106
|
+
let caller;
|
|
107
|
+
try { caller = callerSnapshot(await evaluate(() => authenticate(authenticationContext, {signal:admissionSignal}))); }
|
|
108
|
+
catch { cancelled(); deny('authentication_required',401); }
|
|
109
|
+
cancelled();
|
|
110
|
+
if (action.requiredScopes.some(scope => !caller.scopes.includes(scope))) deny('missing_scope',403);
|
|
111
|
+
const context = Object.freeze({caller, args, signal:admissionSignal});
|
|
112
|
+
let valid;
|
|
113
|
+
try { valid = await evaluate(() => action.validate(args)); } catch { cancelled(); deny('invalid_arguments',400); }
|
|
114
|
+
cancelled();
|
|
115
|
+
if (valid !== true) deny('invalid_arguments',400);
|
|
116
|
+
let authorized;
|
|
117
|
+
try { authorized = await evaluate(() => action.authorize(context)); } catch { cancelled(); deny('authorization_unavailable',503); }
|
|
118
|
+
cancelled();
|
|
119
|
+
if (authorized !== true) deny('permission_denied',403);
|
|
120
|
+
if (action.policy) {
|
|
121
|
+
let permitted;
|
|
122
|
+
try { permitted = await evaluate(() => action.policy(context)); } catch { cancelled(); deny('policy_unavailable',503); }
|
|
123
|
+
cancelled();
|
|
124
|
+
if (permitted !== true) deny('policy_denied',403);
|
|
125
|
+
}
|
|
126
|
+
// Shared limits run only after application permission checks. Their own
|
|
127
|
+
// RPC deadlines are separate from local admission and detector availability.
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
const controls=runtime?.limits.get(name);
|
|
130
|
+
for(const gate of controls?.gates??[]){
|
|
131
|
+
const r=await gate(context,admissionSignal);checks.push(r.check);
|
|
132
|
+
if(r.denial)deny(r.denial.reason,r.denial.status,r.denial.retryAfterSeconds);
|
|
133
|
+
}
|
|
134
|
+
if(controls?.concurrency){
|
|
135
|
+
lease=await controls.concurrency(context,admissionSignal);checks.push(lease.check);
|
|
136
|
+
if(lease.denial)deny(lease.denial.reason,lease.denial.status,lease.denial.retryAfterSeconds);
|
|
137
|
+
lease.signal.throwIfAborted();
|
|
138
|
+
}
|
|
139
|
+
// Authentication may expire while ownership/policy checks are running.
|
|
140
|
+
if (caller.expiresAt <= Date.now()) deny('authentication_expired',401);
|
|
141
|
+
cancelled();
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
attempted = true;
|
|
144
|
+
emit('allow','authorized','attempted');
|
|
145
|
+
const execution=Object.freeze({...context,signal:lease?.signal??context.signal});
|
|
146
|
+
const result = await action.execute(execution);
|
|
147
|
+
execution.signal.throwIfAborted();
|
|
148
|
+
// Resolution is application completion, not proof of an external side effect.
|
|
149
|
+
cancelled();
|
|
150
|
+
completed=true;
|
|
151
|
+
if(lease&&!lease.denial){
|
|
152
|
+
const began=performance.now();
|
|
153
|
+
try{await lease.finish(true);}catch{checks.push({id:'concurrency_release',source:'shared',mode:lease.check.mode,decision:'unavailable',reason:'concurrency_release_unavailable',durationMs:performance.now()-began});}
|
|
154
|
+
}
|
|
155
|
+
emit('allow','authorized','completed');
|
|
156
|
+
return result;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (!attempted && deadline.signal.aborted && !signal?.aborted) deny('admission_timeout',503);
|
|
159
|
+
if (attempted) emit('allow',signal?.aborted ? 'execution_cancelled' : 'execution_failed','unknown');
|
|
160
|
+
else if (!(error instanceof ActionDenied)) emit('deny','admission_cancelled','not_attempted');
|
|
161
|
+
throw error;
|
|
162
|
+
} finally {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
// Only confirmed completion (or no callback) releases; uncertain work holds.
|
|
165
|
+
if(lease&&!lease.denial)await lease.finish(!attempted||completed).catch(()=>{});
|
|
166
|
+
}
|
|
167
|
+
}});
|
|
168
|
+
}
|
package/mcp.d.mts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import type {IncomingMessage, ServerResponse} from 'node:http';
|
|
3
|
+
import type {CallToolResult, Tool} from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import type {ActionContext, ActionDefinition, ActionEvent, ActionRuntime, TrustedCaller} from './actions.mjs';
|
|
5
|
+
|
|
6
|
+
/** Explicitly registered tool; inputSchema describes discovery, validate enforces input. */
|
|
7
|
+
export interface ProtectedTool extends ActionDefinition {
|
|
8
|
+
description: string;
|
|
9
|
+
inputSchema: Tool['inputSchema'];
|
|
10
|
+
/** Await all protected work and return a complete MCP result, never a detached stream. */
|
|
11
|
+
execute(context: ActionContext): CallToolResult | Promise<CallToolResult>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ProtectedMCPOptions {
|
|
15
|
+
/** Exact external /mcp URL. HTTPS required except on loopback. */
|
|
16
|
+
resource: string;
|
|
17
|
+
/** HTTPS issuer URL used in protected-resource metadata. */
|
|
18
|
+
authorizationServer: string;
|
|
19
|
+
/** Verify each request's credential and resolve membership from trusted application state. */
|
|
20
|
+
authenticate(request: Request, options: {signal: AbortSignal}): Promise<TrustedCaller>;
|
|
21
|
+
policyVersion: string;
|
|
22
|
+
sharedRuntime?: ActionRuntime;
|
|
23
|
+
tools: Record<string, ProtectedTool>;
|
|
24
|
+
/** Exact browser origins; requests without Origin are permitted after authentication. */
|
|
25
|
+
allowedOrigins?: string[];
|
|
26
|
+
/** Best-effort, sanitized action events. */
|
|
27
|
+
onEvent?(event: ActionEvent): void | Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Stateless Node HTTP handler for a closed MCP tool registry.
|
|
32
|
+
* Requires the optional @modelcontextprotocol/sdk peer (tested/pinned 1.31.0).
|
|
33
|
+
* Does not wrap existing MCP servers, resources, prompts, tasks or alternate routes.
|
|
34
|
+
*/
|
|
35
|
+
export function createProtectedMCPHandler(options: ProtectedMCPOptions):
|
|
36
|
+
(request: IncomingMessage, response: ServerResponse) => Promise<void>;
|
package/mcp.mjs
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { createActionProtection, ActionDenied } from './actions.mjs';
|
|
5
|
+
const metadataPath = '/.well-known/oauth-protected-resource/mcp';
|
|
6
|
+
export function createProtectedMCPHandler(options) {
|
|
7
|
+
const resource = new URL(options.resource), issuer = new URL(options.authorizationServer);
|
|
8
|
+
const loopback = ['127.0.0.1', 'localhost', '[::1]'].includes(resource.hostname);
|
|
9
|
+
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
|
+
throw Error('Invalid MCP resource configuration');
|
|
11
|
+
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) }]));
|
|
13
|
+
// Validate the closed registry at startup, not only after a client arrives.
|
|
14
|
+
createActionProtection({ policyVersion: options.policyVersion, authenticate: options.authenticate, actions: tools, sharedRuntime: options.sharedRuntime });
|
|
15
|
+
for (const tool of Object.values(tools))
|
|
16
|
+
for (const scope of tool.requiredScopes)
|
|
17
|
+
if (!/^[\x21\x23-\x5b\x5d-\x7e]+$/.test(scope))
|
|
18
|
+
throw Error('Invalid OAuth scope');
|
|
19
|
+
const active = new Map();
|
|
20
|
+
const scopes = [...new Set(Object.values(tools).flatMap(t => t.requiredScopes))];
|
|
21
|
+
const origins = new Set(options.allowedOrigins ?? []);
|
|
22
|
+
function respond(res, status, error, headers = {}) { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...headers }); res.end(JSON.stringify({ error })); }
|
|
23
|
+
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
|
+
return async function handle(req, res) {
|
|
25
|
+
const disconnected = new AbortController();
|
|
26
|
+
req.once('aborted', () => disconnected.abort());
|
|
27
|
+
res.once('close', () => { if (!res.writableEnded)
|
|
28
|
+
disconnected.abort(); });
|
|
29
|
+
try {
|
|
30
|
+
if (req.headers.host !== resource.host) {
|
|
31
|
+
respond(res, 403, 'invalid_host');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (req.headers.origin && !origins.has(req.headers.origin)) {
|
|
35
|
+
respond(res, 403, 'invalid_origin');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (req.url === metadataPath && req.method === 'GET') {
|
|
39
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
40
|
+
res.end(JSON.stringify({ resource: resource.href, authorization_servers: [issuer.href], scopes_supported: scopes, bearer_methods_supported: ['header'] }));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (req.url !== '/mcp') {
|
|
44
|
+
respond(res, 404, 'not_found');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (req.method !== 'POST') {
|
|
48
|
+
respond(res, 405, 'method_not_allowed', { 'Allow': 'POST' });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// Stateless operation: no session resurrection, replay store or standalone SSE.
|
|
52
|
+
if (req.headers['mcp-session-id'] || req.headers['last-event-id']) {
|
|
53
|
+
respond(res, 400, 'sessions_not_supported');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (req.headers['content-type']?.split(';')[0].trim().toLowerCase() !== 'application/json') {
|
|
57
|
+
respond(res, 415, 'application_json_required');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const headers = new Headers();
|
|
61
|
+
for (const [key, value] of Object.entries(req.headers))
|
|
62
|
+
if (value !== undefined)
|
|
63
|
+
headers.set(key, Array.isArray(value) ? value.join(',') : value);
|
|
64
|
+
const original = new Request(resource, { method: 'POST', headers, signal: disconnected.signal });
|
|
65
|
+
let caller;
|
|
66
|
+
const authDeadline = AbortSignal.timeout(1000), authSignal = AbortSignal.any([disconnected.signal, authDeadline]);
|
|
67
|
+
// The action boundary also rechecks expiry before tool execution.
|
|
68
|
+
try {
|
|
69
|
+
caller = await Promise.race([options.authenticate(original, { signal: authSignal }), new Promise((_, reject) => {
|
|
70
|
+
if (authSignal.aborted)
|
|
71
|
+
reject(authSignal.reason);
|
|
72
|
+
else
|
|
73
|
+
authSignal.addEventListener('abort', () => reject(authSignal.reason), { once: true });
|
|
74
|
+
})]);
|
|
75
|
+
authSignal.throwIfAborted();
|
|
76
|
+
if (caller.schema !== 1 || !caller.subject || !caller.tenant || !Array.isArray(caller.scopes) || caller.expiresAt <= Date.now())
|
|
77
|
+
throw Error('Invalid identity');
|
|
78
|
+
caller = Object.freeze({ ...caller, scopes: Object.freeze([...caller.scopes]) });
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
if (!disconnected.signal.aborted)
|
|
82
|
+
challenge(res, 401);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
let body;
|
|
86
|
+
const bodyTimer = setTimeout(() => req.destroy(), 5000);
|
|
87
|
+
try {
|
|
88
|
+
let size = 0;
|
|
89
|
+
const chunks = [];
|
|
90
|
+
for await (const chunk of req) {
|
|
91
|
+
size += chunk.length;
|
|
92
|
+
if (size > 16384) {
|
|
93
|
+
respond(res, 413, 'request_too_large');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
chunks.push(Buffer.from(chunk));
|
|
97
|
+
}
|
|
98
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
if (!res.destroyed)
|
|
102
|
+
respond(res, 400, 'invalid_json');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
clearTimeout(bodyTimer);
|
|
107
|
+
}
|
|
108
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
109
|
+
respond(res, 400, 'single_message_required');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (caller.expiresAt <= Date.now()) {
|
|
113
|
+
challenge(res, 401);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const message = body;
|
|
117
|
+
const validID = (id) => typeof id === 'string' ? id.length > 0 && id.length <= 512 : typeof id === 'number' && Number.isSafeInteger(id);
|
|
118
|
+
const callKey = (id) => JSON.stringify([caller.issuer, caller.subject, caller.tenant, caller.clientId ?? null, id]);
|
|
119
|
+
if (message.jsonrpc === '2.0' && message.method === 'notifications/cancelled' && message.id === undefined) {
|
|
120
|
+
if (!validID(message.params?.requestId)) {
|
|
121
|
+
respond(res, 400, 'invalid_cancellation');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
active.get(callKey(message.params.requestId))?.controller.abort();
|
|
125
|
+
res.writeHead(202, { 'Cache-Control': 'no-store' });
|
|
126
|
+
res.end();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const guard = createActionProtection({ policyVersion: options.policyVersion, authenticate: () => caller, actions: tools, sharedRuntime: options.sharedRuntime, onEvent: options.onEvent });
|
|
130
|
+
// Scope escalation belongs at HTTP level, before the SDK opens an SSE stream.
|
|
131
|
+
if (message.method === 'tools/call' && typeof message.params?.name === 'string') {
|
|
132
|
+
const definition = Object.hasOwn(tools, message.params.name) ? tools[message.params.name] : undefined;
|
|
133
|
+
if (definition && definition.requiredScopes.some(s => !caller.scopes.includes(s))) {
|
|
134
|
+
// Run the same admission path for its sanitized denial evidence. It cannot
|
|
135
|
+
// dispatch with a missing required scope, regardless of discovery results.
|
|
136
|
+
try {
|
|
137
|
+
await guard.run(message.params.name, {}, null, { signal: disconnected.signal });
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
if (!(e instanceof ActionDenied))
|
|
141
|
+
throw e;
|
|
142
|
+
}
|
|
143
|
+
challenge(res, 403, definition.requiredScopes);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const key = message.method === 'tools/call' && typeof message.params?.name === 'string' && Object.hasOwn(tools, message.params.name) && validID(message.id) ? callKey(message.id) : null;
|
|
148
|
+
if (key && active.has(key)) {
|
|
149
|
+
respond(res, 409, 'request_id_in_use');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (key && active.size >= 128) {
|
|
153
|
+
respond(res, 503, 'dispatch_capacity_unavailable');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const running = { controller: new AbortController(), started: false };
|
|
157
|
+
if (key)
|
|
158
|
+
active.set(key, running);
|
|
159
|
+
const cleanup = () => { if (key && active.get(key) === running)
|
|
160
|
+
active.delete(key); };
|
|
161
|
+
res.once('close', () => { if (!running.started)
|
|
162
|
+
cleanup(); });
|
|
163
|
+
const server = new Server({ name: 'webdecoy-protected-tools', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
164
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: false });
|
|
165
|
+
res.once('close', () => { void server.close().catch(() => { }); });
|
|
166
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: Object.entries(tools)
|
|
167
|
+
.filter(([, tool]) => tool.requiredScopes.every(s => caller.scopes.includes(s)))
|
|
168
|
+
.map(([name, tool]) => ({ name, description: tool.description, inputSchema: tool.inputSchema })) }));
|
|
169
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
170
|
+
if (!Object.hasOwn(tools, request.params.name))
|
|
171
|
+
throw new McpError(ErrorCode.InvalidParams, 'Tool unavailable');
|
|
172
|
+
running.started = true;
|
|
173
|
+
const signal = AbortSignal.any([extra.signal, disconnected.signal, running.controller.signal]);
|
|
174
|
+
try {
|
|
175
|
+
return await guard.run(request.params.name, (request.params.arguments ?? {}), null, { signal });
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
if (signal.aborted)
|
|
179
|
+
throw new McpError(ErrorCode.InternalError, 'Request cancelled');
|
|
180
|
+
if (e instanceof ActionDenied)
|
|
181
|
+
return { isError: true, content: [{ type: 'text', text: `Action denied: ${e.reason}` }],
|
|
182
|
+
_meta: { 'webdecoy.com/action-error': { reason: e.reason, status: e.status, ...(e.retryAfterSeconds ? { retryAfterSeconds: e.retryAfterSeconds } : {}) } } };
|
|
183
|
+
return { isError: true, content: [{ type: 'text', text: 'Action failed; outcome may be unknown' }] };
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
cleanup();
|
|
187
|
+
void guard.flush();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
await server.connect(transport);
|
|
191
|
+
await transport.handleRequest(req, res, body);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
if (!res.headersSent && !res.destroyed)
|
|
195
|
+
respond(res, 500, 'request_failed');
|
|
196
|
+
else
|
|
197
|
+
res.destroy();
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
package/package.json
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
"test": "node --test test/*.test.mjs",
|
|
9
9
|
"prepublishOnly": "npm test && npm run test:types && npm run check:package",
|
|
10
10
|
"check:package": "node scripts/check-package.mjs",
|
|
11
|
-
"test:types": "tsc --strict --noEmit --module nodenext --target es2022 test/types.mts"
|
|
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.4",
|
|
14
14
|
"exports": {
|
|
15
15
|
"./fetch": {
|
|
16
16
|
"types": "./fetch.d.mts",
|
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
"./browser": {
|
|
24
24
|
"types": "./browser.d.mts",
|
|
25
25
|
"import": "./browser.mjs"
|
|
26
|
+
},
|
|
27
|
+
"./actions": {
|
|
28
|
+
"types": "./actions.d.mts",
|
|
29
|
+
"import": "./actions.mjs"
|
|
30
|
+
},
|
|
31
|
+
"./mcp": {
|
|
32
|
+
"types": "./mcp.d.mts",
|
|
33
|
+
"import": "./mcp.mjs"
|
|
26
34
|
}
|
|
27
35
|
},
|
|
28
36
|
"files": [
|
|
@@ -46,7 +54,13 @@
|
|
|
46
54
|
"browser-evidence.mjs",
|
|
47
55
|
"usage.mjs",
|
|
48
56
|
"transport.mjs",
|
|
49
|
-
"NOTICE"
|
|
57
|
+
"NOTICE",
|
|
58
|
+
"actions.mjs",
|
|
59
|
+
"actions.d.mts",
|
|
60
|
+
"action-runtime.mjs",
|
|
61
|
+
"mcp.mjs",
|
|
62
|
+
"mcp.d.mts",
|
|
63
|
+
"MCP.md"
|
|
50
64
|
],
|
|
51
65
|
"description": "Bot and abuse protection for AI-powered applications. Server-side request admission for Node.js.",
|
|
52
66
|
"license": "Apache-2.0",
|
|
@@ -70,6 +84,16 @@
|
|
|
70
84
|
"webdecoy"
|
|
71
85
|
],
|
|
72
86
|
"devDependencies": {
|
|
73
|
-
"typescript": "6.0.3"
|
|
87
|
+
"typescript": "6.0.3",
|
|
88
|
+
"@modelcontextprotocol/sdk": "1.31.0",
|
|
89
|
+
"@types/node": "22.19.15"
|
|
90
|
+
},
|
|
91
|
+
"peerDependencies": {
|
|
92
|
+
"@modelcontextprotocol/sdk": "1.31.0"
|
|
93
|
+
},
|
|
94
|
+
"peerDependenciesMeta": {
|
|
95
|
+
"@modelcontextprotocol/sdk": {
|
|
96
|
+
"optional": true
|
|
97
|
+
}
|
|
74
98
|
}
|
|
75
99
|
}
|