@webdecoy/ai-protection 0.1.0-alpha.1 → 0.1.0-alpha.11
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/ARCHITECTURE.md +3 -6
- package/MCP.md +322 -0
- package/NEXTJS.md +1 -1
- package/README.md +51 -12
- package/WORK.md +123 -0
- package/action-runtime.mjs +48 -0
- package/actions.d.mts +105 -0
- package/actions.mjs +181 -0
- package/mcp.d.mts +49 -0
- package/mcp.mjs +252 -0
- package/package.json +31 -5
- package/tool-effects.mjs +52 -0
- package/work.mjs +53 -0
- package/RELEASE.md +0 -75
package/actions.d.mts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
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 work?: {readonly maxUnits:number};
|
|
17
|
+
readonly caller: TrustedCaller;
|
|
18
|
+
readonly args: ActionInput;
|
|
19
|
+
readonly signal?: AbortSignal;
|
|
20
|
+
}
|
|
21
|
+
export interface ActionWorkEvidence {
|
|
22
|
+
readonly rule_id:string;readonly mode:'observe'|'enforce';
|
|
23
|
+
readonly status:'reserved'|'settled'|'unknown'|'unavailable'|'denied'|'replay';
|
|
24
|
+
readonly reserved_units:number;readonly charged_units?:number;readonly remaining_units?:number;
|
|
25
|
+
}
|
|
26
|
+
export interface ActionWork {
|
|
27
|
+
ruleId:string;windowSeconds:number;maxUnits:number;
|
|
28
|
+
limits:{caller?:number;tenant?:number;tool?:number};
|
|
29
|
+
mode?:'observe'|'enforce';failureMode?:'open'|'closed';timeoutMs?:number;
|
|
30
|
+
/** Trusted server-generated persisted ID. Never use MCP request/session IDs or raw user input. */
|
|
31
|
+
operationId?(context:ActionContext):string;
|
|
32
|
+
/** Confirmed work after completed execution. No async/detached measurement; excess/unknown retains maximum. */
|
|
33
|
+
measure?(result:unknown,context:ActionContext):number;
|
|
34
|
+
}
|
|
35
|
+
/** Server-supplied metadata, not proof of a verified schema or authorization. */
|
|
36
|
+
export interface ToolEffectEvidence {
|
|
37
|
+
readonly schema: 1;
|
|
38
|
+
readonly level: 'unknown' | 'read_only' | 'mutating' | 'destructive';
|
|
39
|
+
readonly reason: 'insufficient_signals' | 'annotation_read_only' | 'name_read_only' | 'annotation_mutating' | 'name_mutating' | 'schema_mutating' | 'annotation_destructive' | 'name_destructive' | 'schema_destructive' | 'conflicting_hints';
|
|
40
|
+
}
|
|
41
|
+
export interface ToolPermissionEvidence { readonly schema: 1; readonly required_scopes: number; readonly application_authorization: true; readonly additional_policy: boolean; }
|
|
42
|
+
export interface ToolSchemaEvidence { readonly decoy?: 'advertised' | 'unadvertised'; readonly permissions?: ToolPermissionEvidence; readonly serverId: string; readonly hash: string; readonly effect?: ToolEffectEvidence; }
|
|
43
|
+
export interface ActionEvent {
|
|
44
|
+
readonly toolSchema?: ToolSchemaEvidence;
|
|
45
|
+
/** Pseudonymous application-authenticated subject, not WebDecoy-verified agent identity. */
|
|
46
|
+
readonly caller?: {readonly schema:1;readonly source:'application_auth';readonly id:string};
|
|
47
|
+
readonly work?:ActionWorkEvidence;
|
|
48
|
+
readonly schema: 1;
|
|
49
|
+
readonly eventId: string;
|
|
50
|
+
readonly timestamp: string;
|
|
51
|
+
readonly checks: readonly {id:string;source:string;mode:string;decision:string;reason:string;durationMs:number}[];
|
|
52
|
+
readonly actionId: string;
|
|
53
|
+
readonly action: string;
|
|
54
|
+
readonly policyVersion: string;
|
|
55
|
+
readonly evaluation: 'local';
|
|
56
|
+
readonly decision: 'allow' | 'deny';
|
|
57
|
+
readonly reason: string;
|
|
58
|
+
readonly attempted: boolean;
|
|
59
|
+
readonly outcome: 'not_attempted' | 'attempted' | 'completed' | 'unknown';
|
|
60
|
+
}
|
|
61
|
+
export interface ActionQuota {ruleId:string;limit:number;windowSeconds:number;mode?:'observe'|'enforce';failureMode?:'open'|'closed';timeoutMs?:number}
|
|
62
|
+
export interface ActionLimits {
|
|
63
|
+
tenantConcurrency?: ActionLimits['concurrency'];
|
|
64
|
+
work?:ActionWork;
|
|
65
|
+
callerQuota?: ActionQuota;
|
|
66
|
+
tenantQuota?: ActionQuota;
|
|
67
|
+
concurrency?: {ruleId:string;accountLimit:number;featureLimit:number;mode?:'observe'|'enforce';failureMode?:'open'|'closed';ttlSeconds?:number;maxSeconds?:number;timeoutMs?:number};
|
|
68
|
+
}
|
|
69
|
+
export interface ActionRuntime {
|
|
70
|
+
/** Opt in to scoped caller pseudonyms in reports. Requires a supporting runtime. Default false. */
|
|
71
|
+
reportCaller?:boolean;
|
|
72
|
+
webdecoyUrl:string;webdecoyKey:string;propertyId:string;subjectSecret:string;
|
|
73
|
+
reportingTimeoutMs?:number;maxPendingReports?:number;
|
|
74
|
+
}
|
|
75
|
+
export interface ActionDefinition {
|
|
76
|
+
/** Optional non-secret server label and canonical SHA-256 schema hash. MCP discovery fills this automatically.
|
|
77
|
+
* Requires a runtime supporting tool_schema evidence; does not change admission.
|
|
78
|
+
*/
|
|
79
|
+
toolSchema?: ToolSchemaEvidence;
|
|
80
|
+
limits?: ActionLimits;
|
|
81
|
+
requiredScopes: readonly string[];
|
|
82
|
+
validate(args: ActionInput): boolean | Promise<boolean>;
|
|
83
|
+
authorize(context: ActionContext): boolean | Promise<boolean>;
|
|
84
|
+
/** Additional restrictive policy; cannot override application authorization. */
|
|
85
|
+
policy?(context: ActionContext): boolean | Promise<boolean>;
|
|
86
|
+
/** Await all protected work. Do not return a live stream or detached task. */
|
|
87
|
+
execute(context: ActionContext): unknown | Promise<unknown>;
|
|
88
|
+
}
|
|
89
|
+
export class ActionDenied extends Error {
|
|
90
|
+
readonly reason: string;
|
|
91
|
+
readonly status: number;
|
|
92
|
+
readonly actionId: string;
|
|
93
|
+
readonly retryAfterSeconds?: number;
|
|
94
|
+
constructor(reason: string, status: number, actionId: string);
|
|
95
|
+
}
|
|
96
|
+
export function createActionProtection<T>(options: {
|
|
97
|
+
policyVersion: string;
|
|
98
|
+
sharedRuntime?: ActionRuntime;
|
|
99
|
+
/** Bounds local admission checks; shared controls have their own RPC deadlines. Default 1000 ms, max 10000. */
|
|
100
|
+
admissionTimeoutMs?: number;
|
|
101
|
+
authenticate(context: T, options: {signal?: AbortSignal}): TrustedCaller | Promise<TrustedCaller>;
|
|
102
|
+
actions: Record<string, ActionDefinition>;
|
|
103
|
+
/** Local best-effort observer. sharedRuntime enables independent hosted reporting. */
|
|
104
|
+
onEvent?(event: ActionEvent): void | Promise<void>;
|
|
105
|
+
}): {flush():Promise<void>;run(action: string, args: ActionInput, authenticationContext: T, options?: {signal?: AbortSignal}): Promise<unknown>};
|
package/actions.mjs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
|
+
import {abortable} from './transport.mjs';
|
|
3
|
+
import {prepareActionRuntime} from './action-runtime.mjs';
|
|
4
|
+
|
|
5
|
+
import { snapshotToolEffect, snapshotToolPermissions } from './tool-effects.mjs';
|
|
6
|
+
const token = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,95}$/;
|
|
7
|
+
const bounded = value => typeof value === 'string' && value.isWellFormed() && value.length > 0 && value.length <= 512 && !/[\x00-\x1f\x7f]/.test(value);
|
|
8
|
+
const freeze = value => {
|
|
9
|
+
if (value && typeof value === 'object') { for (const child of Object.values(value)) freeze(child); Object.freeze(value); }
|
|
10
|
+
return value;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export class ActionDenied extends Error {
|
|
14
|
+
constructor(reason, status, actionId) {
|
|
15
|
+
super(reason); this.name = 'ActionDenied'; this.reason = reason; this.status = status; this.actionId = actionId;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Authentication is owned by the application's server integration. This validates
|
|
20
|
+
// its contract; it does not verify a bearer token, signer, or model-supplied claim.
|
|
21
|
+
function callerSnapshot(value) {
|
|
22
|
+
if (!value || value.schema !== 1 || !bounded(value.subject) || !bounded(value.tenant) ||
|
|
23
|
+
!bounded(value.issuer) || !bounded(value.authenticationMethod) ||
|
|
24
|
+
!Number.isSafeInteger(value.expiresAt) || value.expiresAt <= Date.now() ||
|
|
25
|
+
!Array.isArray(value.scopes) || value.scopes.length > 64 || value.scopes.some(s => !bounded(s)) ||
|
|
26
|
+
(value.clientId !== undefined && !bounded(value.clientId))) throw Error('Invalid authenticated caller');
|
|
27
|
+
return freeze({schema:1, subject:value.subject, tenant:value.tenant, issuer:value.issuer,
|
|
28
|
+
authenticationMethod:value.authenticationMethod, expiresAt:value.expiresAt,
|
|
29
|
+
scopes:[...new Set(value.scopes)], ...(value.clientId === undefined ? {} : {clientId:value.clientId})});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// No accessors, class instances, cycles, non-finite numbers, or unbounded trees.
|
|
33
|
+
// Copy before any await so a caller cannot swap arguments while policy runs.
|
|
34
|
+
function inputSnapshot(input) {
|
|
35
|
+
let nodes = 0;
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
function copy(value, depth) {
|
|
38
|
+
if (++nodes > 2048 || depth > 12) throw Error('Input too complex');
|
|
39
|
+
if (value === null || typeof value === 'boolean') return value;
|
|
40
|
+
if (typeof value === 'string') { if (value.length > 16384) throw Error('Input too large'); return value; }
|
|
41
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
42
|
+
if (!value || typeof value !== 'object' || seen.has(value) ||
|
|
43
|
+
(!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) throw Error('Invalid JSON input');
|
|
44
|
+
if (Array.isArray(value) && value.length > 2048) throw Error('Input array too large');
|
|
45
|
+
const keys = Object.keys(value);
|
|
46
|
+
if (keys.length > 2048) throw Error('Input too complex');
|
|
47
|
+
seen.add(value);
|
|
48
|
+
const out = Array.isArray(value) ? [] : Object.create(null);
|
|
49
|
+
for (const key of keys) {
|
|
50
|
+
if (Array.isArray(value) && !/^(0|[1-9][0-9]*)$/.test(key)) throw Error('Invalid array key');
|
|
51
|
+
const d = Object.getOwnPropertyDescriptor(value, key);
|
|
52
|
+
if (!d || !('value' in d) || key.length > 512 || ['__proto__','constructor','prototype'].includes(key)) throw Error('Invalid input key');
|
|
53
|
+
out[key] = copy(d.value, depth + 1);
|
|
54
|
+
}
|
|
55
|
+
seen.delete(value);
|
|
56
|
+
return Object.freeze(out);
|
|
57
|
+
}
|
|
58
|
+
const result = copy(input, 0);
|
|
59
|
+
if (Buffer.byteLength(JSON.stringify(result)) > 16384) throw Error('Input too large');
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Protected dispatch with local permissions and optional shared controls. Never retries execution. */
|
|
64
|
+
export function createActionProtection(options) {
|
|
65
|
+
if (!options || typeof options.authenticate !== 'function' || (typeof options.policyVersion !== 'string' || !token.test(options.policyVersion))) throw Error('Invalid action configuration');
|
|
66
|
+
const authenticate = options.authenticate, policyVersion = options.policyVersion, sink = options.onEvent;
|
|
67
|
+
if (sink !== undefined && typeof sink !== 'function') throw Error('Invalid event sink');
|
|
68
|
+
const admissionTimeoutMs = options.admissionTimeoutMs ?? 1000;
|
|
69
|
+
if (!Number.isInteger(admissionTimeoutMs) || admissionTimeoutMs < 1 || admissionTimeoutMs > 10000) throw Error('Invalid admission timeout');
|
|
70
|
+
const actions = new Map();
|
|
71
|
+
for (const [name, definition] of Object.entries(options.actions ?? {})) {
|
|
72
|
+
if (!token.test(name) || !definition || !Array.isArray(definition.requiredScopes) || definition.requiredScopes.length > 64 ||
|
|
73
|
+
definition.requiredScopes.some(s => !bounded(s)) ||
|
|
74
|
+
['validate','authorize','execute'].some(k => typeof definition[k] !== 'function') ||
|
|
75
|
+
(definition.policy !== undefined && typeof definition.policy !== 'function')) throw Error('Invalid action definition');
|
|
76
|
+
const t = definition.toolSchema;
|
|
77
|
+
if (t !== undefined && (!t || typeof t.serverId !== 'string' || !token.test(t.serverId) ||
|
|
78
|
+
(t.decoy!==undefined && !['advertised','unadvertised'].includes(t.decoy)) || typeof t.hash !== 'string' || !/^[a-f0-9]{64}$/.test(t.hash))) throw Error('Invalid tool schema evidence');
|
|
79
|
+
const toolSchema = t === undefined ? undefined : Object.freeze({serverId:t.serverId,hash:t.hash,...(t.decoy?{decoy:t.decoy}:{}),...(t.effect===undefined?{}:{effect:snapshotToolEffect(t.effect)}),...(t.permissions===undefined?{}:{permissions:snapshotToolPermissions(t.permissions)})});
|
|
80
|
+
actions.set(name, Object.freeze({...definition,toolSchema,requiredScopes:Object.freeze([...definition.requiredScopes])}));
|
|
81
|
+
}
|
|
82
|
+
if (!actions.size || actions.size > 128) throw Error('Expected 1–128 actions');
|
|
83
|
+
const runtime=prepareActionRuntime(options,actions);
|
|
84
|
+
let pendingEvents = 0;
|
|
85
|
+
return Object.freeze({flush:async()=>{await runtime?.flush();},async run(name, input, authenticationContext, {signal} = {}) {
|
|
86
|
+
const actionId = randomUUID();
|
|
87
|
+
// Unknown caller-controlled action strings are never placed in evidence.
|
|
88
|
+
const action = actions.get(name), eventAction = action ? name : 'unregistered';
|
|
89
|
+
let attempted = false,completed=false,lease,work,callerEvidence;
|
|
90
|
+
const leases=[];
|
|
91
|
+
const checks=[];
|
|
92
|
+
const deadline = new AbortController();
|
|
93
|
+
const admissionSignal = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal;
|
|
94
|
+
const timer = setTimeout(() => deadline.abort(new DOMException('Action admission timed out','TimeoutError')), admissionTimeoutMs);
|
|
95
|
+
const evaluate = fn => abortable(Promise.resolve().then(() => { admissionSignal.throwIfAborted(); return fn(); }), admissionSignal);
|
|
96
|
+
function emit(decision, reason, outcome) {
|
|
97
|
+
|
|
98
|
+
const event = Object.freeze({schema:1, eventId:randomUUID(), timestamp:new Date().toISOString(), actionId, action:eventAction, policyVersion,
|
|
99
|
+
...(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})))});
|
|
100
|
+
if(runtime)void runtime.report(event);
|
|
101
|
+
if(!sink||pendingEvents>=100)return;
|
|
102
|
+
pendingEvents++;
|
|
103
|
+
Promise.resolve().then(() => sink(event)).catch(() => {}).finally(() => { pendingEvents--; });
|
|
104
|
+
}
|
|
105
|
+
function deny(reason, status, retryAfterSeconds) { emit('deny', reason, 'not_attempted'); const e=new ActionDenied(reason,status,actionId);if(retryAfterSeconds)e.retryAfterSeconds=retryAfterSeconds;throw e; }
|
|
106
|
+
function cancelled() { (attempted ? signal : admissionSignal)?.throwIfAborted(); }
|
|
107
|
+
try {
|
|
108
|
+
cancelled();
|
|
109
|
+
if (!action) deny('action_not_registered',403);
|
|
110
|
+
let args;
|
|
111
|
+
try { args = inputSnapshot(input); } catch { deny('invalid_arguments',400); }
|
|
112
|
+
let caller;
|
|
113
|
+
try { caller = callerSnapshot(await evaluate(() => authenticate(authenticationContext, {signal:admissionSignal}))); }
|
|
114
|
+
catch { cancelled(); deny('authentication_required',401); }
|
|
115
|
+
callerEvidence=runtime?.callerEvidence(caller);
|
|
116
|
+
cancelled();
|
|
117
|
+
if (action.requiredScopes.some(scope => !caller.scopes.includes(scope))) deny('missing_scope',403);
|
|
118
|
+
const context = Object.freeze({caller, args, signal:admissionSignal});
|
|
119
|
+
let valid;
|
|
120
|
+
try { valid = await evaluate(() => action.validate(args)); } catch { cancelled(); deny('invalid_arguments',400); }
|
|
121
|
+
cancelled();
|
|
122
|
+
if (valid !== true) deny('invalid_arguments',400);
|
|
123
|
+
let authorized;
|
|
124
|
+
try { authorized = await evaluate(() => action.authorize(context)); } catch { cancelled(); deny('authorization_unavailable',503); }
|
|
125
|
+
cancelled();
|
|
126
|
+
if (authorized !== true) deny('permission_denied',403);
|
|
127
|
+
if (action.policy) {
|
|
128
|
+
let permitted;
|
|
129
|
+
try { permitted = await evaluate(() => action.policy(context)); } catch { cancelled(); deny('policy_unavailable',503); }
|
|
130
|
+
cancelled();
|
|
131
|
+
if (permitted !== true) deny('policy_denied',403);
|
|
132
|
+
}
|
|
133
|
+
// Shared limits run only after application permission checks. Their own
|
|
134
|
+
// RPC deadlines are separate from local admission and detector availability.
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
const controls=runtime?.limits.get(name);
|
|
137
|
+
for(const gate of controls?.gates??[]){
|
|
138
|
+
const r=await gate(context,admissionSignal);checks.push(r.check);
|
|
139
|
+
if(r.denial)deny(r.denial.reason,r.denial.status,r.denial.retryAfterSeconds);
|
|
140
|
+
}
|
|
141
|
+
for(const gate of controls?.concurrencies??[]){
|
|
142
|
+
lease=await gate(context,lease?.signal??admissionSignal);leases.push(lease);checks.push(lease.check);
|
|
143
|
+
if(lease.denial)deny(lease.denial.reason,lease.denial.status,lease.denial.retryAfterSeconds);
|
|
144
|
+
lease.signal.throwIfAborted();
|
|
145
|
+
}
|
|
146
|
+
if(controls?.work){
|
|
147
|
+
work=await controls.work(context,lease?.signal??admissionSignal);checks.push(work.check);
|
|
148
|
+
if(work.denial)deny(work.denial.reason,work.denial.status,work.denial.retryAfterSeconds);
|
|
149
|
+
}
|
|
150
|
+
// Authentication may expire while ownership/policy checks are running.
|
|
151
|
+
if (caller.expiresAt <= Date.now()) deny('authentication_expired',401);
|
|
152
|
+
cancelled();
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
attempted = true;
|
|
155
|
+
emit('allow','authorized','attempted');
|
|
156
|
+
const execution=Object.freeze({...context,...(work?{work:work.bound}:{}),signal:lease?.signal??context.signal});
|
|
157
|
+
const result = await action.execute(execution);
|
|
158
|
+
execution.signal.throwIfAborted();
|
|
159
|
+
// Resolution is application completion, not proof of an external side effect.
|
|
160
|
+
cancelled();
|
|
161
|
+
completed=true;
|
|
162
|
+
if(work){const check=await work.finish(result,true);if(check)checks.push(check);}
|
|
163
|
+
for(const lease of [...leases].reverse())if(!lease.denial){
|
|
164
|
+
const began=performance.now();
|
|
165
|
+
try{await lease.finish(true);}catch{checks.push({id:lease.check.id==='tenant_concurrency'?'tenant_concurrency_release':'concurrency_release',source:'shared',mode:lease.check.mode,decision:'unavailable',reason:'concurrency_release_unavailable',durationMs:performance.now()-began});}
|
|
166
|
+
}
|
|
167
|
+
emit('allow','authorized','completed');
|
|
168
|
+
return result;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (!attempted && deadline.signal.aborted && !signal?.aborted) deny('admission_timeout',503);
|
|
171
|
+
if(work&&!completed)await work.finish(undefined,false);
|
|
172
|
+
if (attempted) emit('allow',signal?.aborted ? 'execution_cancelled' : 'execution_failed','unknown');
|
|
173
|
+
else if (!(error instanceof ActionDenied)) emit('deny','admission_cancelled','not_attempted');
|
|
174
|
+
throw error;
|
|
175
|
+
} finally {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
// Only confirmed completion (or no callback) releases; uncertain work holds.
|
|
178
|
+
for(const lease of [...leases].reverse())if(!lease.denial)await lease.finish(!attempted||completed).catch(()=>{});
|
|
179
|
+
}
|
|
180
|
+
}});
|
|
181
|
+
}
|
package/mcp.d.mts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
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 Omit<ActionDefinition, 'toolSchema'> {
|
|
8
|
+
description: string;
|
|
9
|
+
inputSchema: Tool['inputSchema'];
|
|
10
|
+
/** Behavioral hints, never authorization or verified guarantees. */
|
|
11
|
+
annotations?: Pick<NonNullable<Tool['annotations']>, 'readOnlyHint' | 'destructiveHint' | 'idempotentHint' | 'openWorldHint'>;
|
|
12
|
+
/** Await all protected work and return a complete MCP result, never a detached stream. */
|
|
13
|
+
execute(context: ActionContext): CallToolResult | Promise<CallToolResult>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ProtectedMCPOptions {
|
|
17
|
+
/** Exact external /mcp URL. HTTPS required except on loopback. */
|
|
18
|
+
resource: string;
|
|
19
|
+
/** HTTPS issuer URL used in protected-resource metadata. */
|
|
20
|
+
authorizationServer: string;
|
|
21
|
+
/** Verify each request's credential and resolve membership from trusted application state. */
|
|
22
|
+
authenticate(request: Request, options: {signal: AbortSignal}): Promise<TrustedCaller>;
|
|
23
|
+
policyVersion: string;
|
|
24
|
+
sharedRuntime?: ActionRuntime;
|
|
25
|
+
tools: Record<string, ProtectedTool>;
|
|
26
|
+
/** Opt-in, at most eight named decoys. Requires discovery + sharedRuntime.
|
|
27
|
+
* Never accepts customer execution callbacks. Combined real/decoy registry <=128.
|
|
28
|
+
*/
|
|
29
|
+
decoys?: Record<string, {description: string; visibility: 'advertised' | 'unadvertised'}>;
|
|
30
|
+
/** Opt-in tools/list and tools/call metadata: stable non-secret server label and SHA-256 input-schema hashes.
|
|
31
|
+
* Requires sharedRuntime. Reuse serverId across replicas; separate different servers.
|
|
32
|
+
*/
|
|
33
|
+
discovery?: { serverId: string };
|
|
34
|
+
/** Exact browser origins; requests without Origin are permitted after authentication. */
|
|
35
|
+
allowedOrigins?: string[];
|
|
36
|
+
/** Best-effort, sanitized action events. */
|
|
37
|
+
onEvent?(event: ActionEvent): void | Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Stateless Node HTTP handler for a closed MCP tool registry.
|
|
42
|
+
* Requires the optional @modelcontextprotocol/sdk peer (tested/pinned 1.31.0).
|
|
43
|
+
* Does not wrap existing MCP servers, resources, prompts, tasks or alternate routes.
|
|
44
|
+
*/
|
|
45
|
+
export function createProtectedMCPHandler(options: ProtectedMCPOptions):
|
|
46
|
+
((request: IncomingMessage, response: ServerResponse) => Promise<void>) & {
|
|
47
|
+
/** Drain pending discovery reports at shutdown. Does not wait for running tools. */
|
|
48
|
+
flush(): Promise<void>;
|
|
49
|
+
};
|
package/mcp.mjs
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
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
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
+
import { createReporter } from './reporting.mjs';
|
|
7
|
+
import { inferToolEffect, snapshotToolHints } from './tool-effects.mjs';
|
|
8
|
+
const metadataPath = '/.well-known/oauth-protected-resource/mcp';
|
|
9
|
+
export function createProtectedMCPHandler(options) {
|
|
10
|
+
const resource = new URL(options.resource), issuer = new URL(options.authorizationServer);
|
|
11
|
+
const loopback = ['127.0.0.1', 'localhost', '[::1]'].includes(resource.hostname);
|
|
12
|
+
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)
|
|
13
|
+
throw Error('Invalid MCP resource configuration');
|
|
14
|
+
const metadataURL = new URL(metadataPath, resource).href;
|
|
15
|
+
const tools = Object.fromEntries(Object.entries(options.tools).map(([name, t]) => [name, { ...t, toolSchema: undefined, annotations: snapshotToolHints(t.annotations), requiredScopes: [...t.requiredScopes], inputSchema: structuredClone(t.inputSchema) }]));
|
|
16
|
+
const decoys = new Map();
|
|
17
|
+
if (options.decoys !== undefined) {
|
|
18
|
+
if (!options.discovery || !options.sharedRuntime || !options.decoys || typeof options.decoys !== 'object' || Array.isArray(options.decoys)) throw Error('Decoys require discovery and sharedRuntime');
|
|
19
|
+
const entries = Object.entries(options.decoys);
|
|
20
|
+
if (entries.length > 8) throw Error('At most 8 decoys are supported');
|
|
21
|
+
for (const [name, definition] of entries) {
|
|
22
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,95}$/.test(name) || Object.hasOwn(tools, name) || !definition ||
|
|
23
|
+
Object.keys(definition).some(k => !['description', 'visibility'].includes(k)) ||
|
|
24
|
+
typeof definition.description !== 'string' || !definition.description.trim() || definition.description.length > 512 ||
|
|
25
|
+
!['advertised', 'unadvertised'].includes(definition.visibility)) throw Error('Invalid or colliding decoy definition');
|
|
26
|
+
decoys.set(name, definition.visibility);
|
|
27
|
+
tools[name] = { description: definition.description, inputSchema: {type:'object'}, requiredScopes:[],
|
|
28
|
+
validate: () => true, authorize: () => false,
|
|
29
|
+
execute: () => { throw Error('Decoy dispatch invariant violated'); } };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// Validate the closed registry at startup, not only after a client arrives.
|
|
33
|
+
createActionProtection({ policyVersion: options.policyVersion, authenticate: options.authenticate, actions: tools, sharedRuntime: options.sharedRuntime });
|
|
34
|
+
for (const tool of Object.values(tools))
|
|
35
|
+
for (const scope of tool.requiredScopes)
|
|
36
|
+
if (!/^[\x21\x23-\x5b\x5d-\x7e]+$/.test(scope))
|
|
37
|
+
throw Error('Invalid OAuth scope');
|
|
38
|
+
// Opt-in metadata only. Never collect descriptions, raw schemas, scopes,
|
|
39
|
+
// credentials, arguments or resource URLs in discovery reports.
|
|
40
|
+
if (options.discovery !== undefined && (!options.sharedRuntime || !options.discovery ||
|
|
41
|
+
!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,95}$/.test(options.discovery.serverId)))
|
|
42
|
+
throw Error('Discovery requires sharedRuntime and a stable serverId');
|
|
43
|
+
const canonical = value => {
|
|
44
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
45
|
+
if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
|
|
46
|
+
return '{' + Object.keys(value).sort().map(key => JSON.stringify(key) + ':' + canonical(value[key])).join(',') + '}';
|
|
47
|
+
};
|
|
48
|
+
const hashes = options.discovery ? Object.fromEntries(Object.entries(tools).map(([name, tool]) =>
|
|
49
|
+
[name, createHash('sha256').update(canonical(JSON.parse(JSON.stringify(tool.inputSchema)))).digest('hex')])) : {};
|
|
50
|
+
if (options.discovery) for (const [name, tool] of Object.entries(tools))
|
|
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
|
+
const runtime = options.sharedRuntime && { ...options.sharedRuntime };
|
|
53
|
+
const serverId = options.discovery?.serverId;
|
|
54
|
+
const catalogReporter = options.discovery ? createReporter({
|
|
55
|
+
reportingTimeoutMs: runtime.reportingTimeoutMs ?? 1000,
|
|
56
|
+
maxPendingReports: runtime.maxPendingReports ?? 100,
|
|
57
|
+
onObservation: async (names, { signal }) => {
|
|
58
|
+
const response = await fetch(new URL('/api/v1/sdk/ai-abuse/reports', runtime.webdecoyUrl), {
|
|
59
|
+
method: 'POST', redirect: 'error', signal,
|
|
60
|
+
headers: { Authorization: `Bearer ${runtime.webdecoyKey}`, 'X-WebDecoy-Property-ID': runtime.propertyId, 'Content-Type': 'application/json' },
|
|
61
|
+
body: JSON.stringify({ schema: 3, request_id: randomUUID(), timestamp: new Date().toISOString(), action: 'tool_discovery',
|
|
62
|
+
tool_catalog: { server_id: serverId, source: 'tools_list', tools: names.map(name => ({ name, schema_hash: hashes[name], effect: tools[name].toolSchema.effect, permissions: tools[name].toolSchema.permissions, ...(decoys.has(name)?{decoy:decoys.get(name)}:{}) })) } })
|
|
63
|
+
});
|
|
64
|
+
await response.body?.cancel();
|
|
65
|
+
if (!response.ok) throw Error('Discovery reporting unavailable');
|
|
66
|
+
}
|
|
67
|
+
}) : null;
|
|
68
|
+
const active = new Map();
|
|
69
|
+
const scopes = [...new Set(Object.values(tools).flatMap(t => t.requiredScopes))];
|
|
70
|
+
const origins = new Set(options.allowedOrigins ?? []);
|
|
71
|
+
function respond(res, status, error, headers = {}) { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...headers }); res.end(JSON.stringify({ error })); }
|
|
72
|
+
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(' ')}"`}` }); }
|
|
73
|
+
const handle = async function (req, res) {
|
|
74
|
+
const disconnected = new AbortController();
|
|
75
|
+
req.once('aborted', () => disconnected.abort());
|
|
76
|
+
res.once('close', () => { if (!res.writableEnded)
|
|
77
|
+
disconnected.abort(); });
|
|
78
|
+
try {
|
|
79
|
+
if (req.headers.host !== resource.host) {
|
|
80
|
+
respond(res, 403, 'invalid_host');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (req.headers.origin && !origins.has(req.headers.origin)) {
|
|
84
|
+
respond(res, 403, 'invalid_origin');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (req.url === metadataPath && req.method === 'GET') {
|
|
88
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
89
|
+
res.end(JSON.stringify({ resource: resource.href, authorization_servers: [issuer.href], scopes_supported: scopes, bearer_methods_supported: ['header'] }));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (req.url !== '/mcp') {
|
|
93
|
+
respond(res, 404, 'not_found');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (req.method !== 'POST') {
|
|
97
|
+
respond(res, 405, 'method_not_allowed', { 'Allow': 'POST' });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// Stateless operation: no session resurrection, replay store or standalone SSE.
|
|
101
|
+
if (req.headers['mcp-session-id'] || req.headers['last-event-id']) {
|
|
102
|
+
respond(res, 400, 'sessions_not_supported');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (req.headers['content-type']?.split(';')[0].trim().toLowerCase() !== 'application/json') {
|
|
106
|
+
respond(res, 415, 'application_json_required');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const headers = new Headers();
|
|
110
|
+
for (const [key, value] of Object.entries(req.headers))
|
|
111
|
+
if (value !== undefined)
|
|
112
|
+
headers.set(key, Array.isArray(value) ? value.join(',') : value);
|
|
113
|
+
const original = new Request(resource, { method: 'POST', headers, signal: disconnected.signal });
|
|
114
|
+
let caller;
|
|
115
|
+
const authDeadline = AbortSignal.timeout(1000), authSignal = AbortSignal.any([disconnected.signal, authDeadline]);
|
|
116
|
+
// The action boundary also rechecks expiry before tool execution.
|
|
117
|
+
try {
|
|
118
|
+
caller = await Promise.race([options.authenticate(original, { signal: authSignal }), new Promise((_, reject) => {
|
|
119
|
+
if (authSignal.aborted)
|
|
120
|
+
reject(authSignal.reason);
|
|
121
|
+
else
|
|
122
|
+
authSignal.addEventListener('abort', () => reject(authSignal.reason), { once: true });
|
|
123
|
+
})]);
|
|
124
|
+
authSignal.throwIfAborted();
|
|
125
|
+
if (caller.schema !== 1 || !caller.subject || !caller.tenant || !Array.isArray(caller.scopes) || caller.expiresAt <= Date.now())
|
|
126
|
+
throw Error('Invalid identity');
|
|
127
|
+
caller = Object.freeze({ ...caller, scopes: Object.freeze([...caller.scopes]) });
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
if (!disconnected.signal.aborted)
|
|
131
|
+
challenge(res, 401);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
let body;
|
|
135
|
+
const bodyTimer = setTimeout(() => req.destroy(), 5000);
|
|
136
|
+
try {
|
|
137
|
+
let size = 0;
|
|
138
|
+
const chunks = [];
|
|
139
|
+
for await (const chunk of req) {
|
|
140
|
+
size += chunk.length;
|
|
141
|
+
if (size > 16384) {
|
|
142
|
+
respond(res, 413, 'request_too_large');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
chunks.push(Buffer.from(chunk));
|
|
146
|
+
}
|
|
147
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
if (!res.destroyed)
|
|
151
|
+
respond(res, 400, 'invalid_json');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
clearTimeout(bodyTimer);
|
|
156
|
+
}
|
|
157
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
158
|
+
respond(res, 400, 'single_message_required');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (caller.expiresAt <= Date.now()) {
|
|
162
|
+
challenge(res, 401);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const message = body;
|
|
166
|
+
const validID = (id) => typeof id === 'string' ? id.length > 0 && id.length <= 512 : typeof id === 'number' && Number.isSafeInteger(id);
|
|
167
|
+
const callKey = (id) => JSON.stringify([caller.issuer, caller.subject, caller.tenant, caller.clientId ?? null, id]);
|
|
168
|
+
if (message.jsonrpc === '2.0' && message.method === 'notifications/cancelled' && message.id === undefined) {
|
|
169
|
+
if (!validID(message.params?.requestId)) {
|
|
170
|
+
respond(res, 400, 'invalid_cancellation');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
active.get(callKey(message.params.requestId))?.controller.abort();
|
|
174
|
+
res.writeHead(202, { 'Cache-Control': 'no-store' });
|
|
175
|
+
res.end();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const guard = createActionProtection({ policyVersion: options.policyVersion, authenticate: () => caller, actions: tools, sharedRuntime: options.sharedRuntime, onEvent: options.onEvent });
|
|
179
|
+
// Scope escalation belongs at HTTP level, before the SDK opens an SSE stream.
|
|
180
|
+
if (message.method === 'tools/call' && typeof message.params?.name === 'string') {
|
|
181
|
+
const definition = Object.hasOwn(tools, message.params.name) ? tools[message.params.name] : undefined;
|
|
182
|
+
if (definition && definition.requiredScopes.some(s => !caller.scopes.includes(s))) {
|
|
183
|
+
// Run the same admission path for its sanitized denial evidence. It cannot
|
|
184
|
+
// dispatch with a missing required scope, regardless of discovery results.
|
|
185
|
+
try {
|
|
186
|
+
await guard.run(message.params.name, {}, null, { signal: disconnected.signal });
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
if (!(e instanceof ActionDenied))
|
|
190
|
+
throw e;
|
|
191
|
+
}
|
|
192
|
+
challenge(res, 403, definition.requiredScopes);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const key = message.method === 'tools/call' && typeof message.params?.name === 'string' && Object.hasOwn(tools, message.params.name) && validID(message.id) ? callKey(message.id) : null;
|
|
197
|
+
if (key && active.has(key)) {
|
|
198
|
+
respond(res, 409, 'request_id_in_use');
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (key && active.size >= 128) {
|
|
202
|
+
respond(res, 503, 'dispatch_capacity_unavailable');
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const running = { controller: new AbortController(), started: false };
|
|
206
|
+
if (key)
|
|
207
|
+
active.set(key, running);
|
|
208
|
+
const cleanup = () => { if (key && active.get(key) === running)
|
|
209
|
+
active.delete(key); };
|
|
210
|
+
res.once('close', () => { if (!running.started)
|
|
211
|
+
cleanup(); });
|
|
212
|
+
const server = new Server({ name: 'webdecoy-protected-tools', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
213
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: false });
|
|
214
|
+
res.once('close', () => { void server.close().catch(() => { }); });
|
|
215
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
216
|
+
const visible = Object.entries(tools).filter(([name, tool]) => decoys.get(name) !== 'unadvertised' && tool.requiredScopes.every(s => caller.scopes.includes(s)));
|
|
217
|
+
for (let i=0;i<visible.length;i+=64) void catalogReporter?.send(visible.slice(i,i+64).map(([name]) => name));
|
|
218
|
+
return { tools: visible.map(([name, tool]) => ({ name, description: tool.description, inputSchema: tool.inputSchema, ...(tool.annotations ? {annotations:tool.annotations} : {}) })) };
|
|
219
|
+
});
|
|
220
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
221
|
+
if (!Object.hasOwn(tools, request.params.name))
|
|
222
|
+
throw new McpError(ErrorCode.InvalidParams, 'Tool unavailable');
|
|
223
|
+
running.started = true;
|
|
224
|
+
const signal = AbortSignal.any([extra.signal, disconnected.signal, running.controller.signal]);
|
|
225
|
+
try {
|
|
226
|
+
return await guard.run(request.params.name, (request.params.arguments ?? {}), null, { signal });
|
|
227
|
+
}
|
|
228
|
+
catch (e) {
|
|
229
|
+
if (signal.aborted)
|
|
230
|
+
throw new McpError(ErrorCode.InternalError, 'Request cancelled');
|
|
231
|
+
if (e instanceof ActionDenied)
|
|
232
|
+
return { isError: true, content: [{ type: 'text', text: `Action denied: ${e.reason}` }],
|
|
233
|
+
_meta: { 'webdecoy.com/action-error': { reason: e.reason, status: e.status, ...(e.retryAfterSeconds ? { retryAfterSeconds: e.retryAfterSeconds } : {}) } } };
|
|
234
|
+
return { isError: true, content: [{ type: 'text', text: 'Action failed; outcome may be unknown' }] };
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
cleanup();
|
|
238
|
+
void guard.flush();
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
await server.connect(transport);
|
|
242
|
+
await transport.handleRequest(req, res, body);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
if (!res.headersSent && !res.destroyed)
|
|
246
|
+
respond(res, 500, 'request_failed');
|
|
247
|
+
else
|
|
248
|
+
res.destroy();
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
return Object.assign(handle, { flush: async () => { await catalogReporter?.flush(); } });
|
|
252
|
+
}
|
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.11",
|
|
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,8 +54,16 @@
|
|
|
46
54
|
"browser-evidence.mjs",
|
|
47
55
|
"usage.mjs",
|
|
48
56
|
"transport.mjs",
|
|
49
|
-
"
|
|
50
|
-
"
|
|
57
|
+
"NOTICE",
|
|
58
|
+
"actions.mjs",
|
|
59
|
+
"actions.d.mts",
|
|
60
|
+
"action-runtime.mjs",
|
|
61
|
+
"mcp.mjs",
|
|
62
|
+
"mcp.d.mts",
|
|
63
|
+
"MCP.md",
|
|
64
|
+
"work.mjs",
|
|
65
|
+
"WORK.md",
|
|
66
|
+
"tool-effects.mjs"
|
|
51
67
|
],
|
|
52
68
|
"description": "Bot and abuse protection for AI-powered applications. Server-side request admission for Node.js.",
|
|
53
69
|
"license": "Apache-2.0",
|
|
@@ -71,6 +87,16 @@
|
|
|
71
87
|
"webdecoy"
|
|
72
88
|
],
|
|
73
89
|
"devDependencies": {
|
|
74
|
-
"typescript": "6.0.3"
|
|
90
|
+
"typescript": "6.0.3",
|
|
91
|
+
"@modelcontextprotocol/sdk": "1.31.0",
|
|
92
|
+
"@types/node": "22.19.15"
|
|
93
|
+
},
|
|
94
|
+
"peerDependencies": {
|
|
95
|
+
"@modelcontextprotocol/sdk": "1.31.0"
|
|
96
|
+
},
|
|
97
|
+
"peerDependenciesMeta": {
|
|
98
|
+
"@modelcontextprotocol/sdk": {
|
|
99
|
+
"optional": true
|
|
100
|
+
}
|
|
75
101
|
}
|
|
76
102
|
}
|