@webdecoy/ai-protection 0.1.0-alpha.3 → 0.1.0-alpha.5
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 +122 -0
- package/README.md +9 -3
- package/WORK.md +123 -0
- package/action-runtime.mjs +11 -5
- package/actions.d.mts +18 -0
- package/actions.mjs +16 -9
- package/mcp.d.mts +36 -0
- package/mcp.mjs +200 -0
- package/package.json +23 -4
- package/work.mjs +53 -0
package/MCP.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
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.5` and later compatible alpha releases:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install @webdecoy/ai-protection@0.1.0-alpha.5 @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 tool work is available in the alpha API; see [bounded work](WORK.md)
|
|
115
|
+
for its runtime prerequisite and application-enforced bounds. Full product
|
|
116
|
+
acceptance remains separate from this adapter's tested contract.
|
|
117
|
+
|
|
118
|
+
This is an explicit tools integration, not transparent protection of an existing
|
|
119
|
+
whole MCP server. See the [MCP transport specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports)
|
|
120
|
+
and [authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
|
|
121
|
+
|
|
122
|
+
For weighted search/export limits and tenant concurrency, see [bounded tool work](WORK.md).
|
package/README.md
CHANGED
|
@@ -324,7 +324,13 @@ Version `0.1.0-alpha.3` includes an action boundary at `@webdecoy/ai-protection/
|
|
|
324
324
|
[record-action example and integration contract](examples/actions/README.md).
|
|
325
325
|
This API requires your server authentication and application authorization.
|
|
326
326
|
An [Auth0 access-token example](examples/auth0/README.md) verifies signed tokens
|
|
327
|
-
and maps tenant membership. The [
|
|
328
|
-
|
|
329
|
-
|
|
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
330
|
action events are documented in the action guide and use the hosted AI Protection runtime and dashboard.
|
|
331
|
+
|
|
332
|
+
## Weighted tool work (Alpha)
|
|
333
|
+
|
|
334
|
+
Node alpha.5 adds weighted tool-work reservations and tenant concurrency. See
|
|
335
|
+
[bounded tool work](WORK.md) for installation, enforced application bounds and
|
|
336
|
+
retry/unknown-outcome semantics. These units are separate from model usage and billing.
|
package/WORK.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Bounded tool work (Alpha)
|
|
2
|
+
|
|
3
|
+
The Node action and MCP APIs support optional weighted tool-work reservations.
|
|
4
|
+
Available in `@webdecoy/ai-protection@0.1.0-alpha.5`. Requires the
|
|
5
|
+
`/api/v1/sdk/ai-abuse/work` contract enabled on the hosted runtime. Go/Python model budgets
|
|
6
|
+
and invocation controls remain available, but do not expose this new weighted
|
|
7
|
+
operation API. The first integration is the TypeScript MCP tools adapter.
|
|
8
|
+
|
|
9
|
+
## Configure at the action, before execution
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
limits: {
|
|
13
|
+
work: {
|
|
14
|
+
ruleId: 'search_work_v1',
|
|
15
|
+
windowSeconds: 60,
|
|
16
|
+
maxUnits: 11, // one fixed unit plus at most five rows at two units each
|
|
17
|
+
limits: {caller: 22, tenant: 44, tool: 88},
|
|
18
|
+
mode: 'enforce',
|
|
19
|
+
failureMode: 'closed',
|
|
20
|
+
measure: result => result.workUnits, // server-computed, synchronous, confirmed
|
|
21
|
+
},
|
|
22
|
+
concurrency: {
|
|
23
|
+
ruleId: 'search_parallel_v1', accountLimit: 1, featureLimit: 10,
|
|
24
|
+
mode: 'enforce', failureMode: 'closed',
|
|
25
|
+
},
|
|
26
|
+
tenantConcurrency: {
|
|
27
|
+
ruleId: 'tenant_search_parallel_v1', accountLimit: 2, featureLimit: 10,
|
|
28
|
+
mode: 'enforce', failureMode: 'closed',
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Use `sharedRuntime` on the action/MCP handler. No browser credentials, prompts,
|
|
34
|
+
arguments or results are sent to the work service. Verified caller identity and
|
|
35
|
+
tenant are hashed; an HMAC binds the action, policy version and canonical arguments
|
|
36
|
+
to the operation. Use a stable server-only subject secret. Changing that secret or
|
|
37
|
+
rule IDs creates new accounting identities; rotate/drain deliberately.
|
|
38
|
+
|
|
39
|
+
All three unit allowances are evaluated atomically across runtime replicas before
|
|
40
|
+
callback dispatch. `caller` is scoped to issuer/tenant/subject, `tenant` is scoped
|
|
41
|
+
to issuer/tenant, and `tool` is shared within this property's rule. Zero/omitted
|
|
42
|
+
limits disable that dimension; at least one positive limit is required. Distinct
|
|
43
|
+
tools need distinct rule IDs. Reconnecting or creating a new MCP session does not
|
|
44
|
+
reset the identity allowance. Invocation quotas count attempts separately; model
|
|
45
|
+
budgets count tokens separately. These units never become a WebDecoy billing meter.
|
|
46
|
+
|
|
47
|
+
Existing `concurrency.accountLimit` is per caller; `tenantConcurrency.accountLimit`
|
|
48
|
+
is per tenant. Each `featureLimit` is property/rule-wide. Admission denial releases
|
|
49
|
+
already acquired slots; unconfirmed work keeps its slots until lease expiry.
|
|
50
|
+
Lease expiry requests cooperative cancellation, not proof a remote operation stopped.
|
|
51
|
+
|
|
52
|
+
## Enforce resource bounds in the application
|
|
53
|
+
|
|
54
|
+
`maxUnits` must be a conservative upper bound you actually enforce. The SDK provides
|
|
55
|
+
`context.work.maxUnits` to the callback, but cannot constrain arbitrary database or
|
|
56
|
+
storage work. Validate requested bounds before dispatch, apply tenant predicates and
|
|
57
|
+
row limits at the data source, and check byte lengths **before** appending/sending
|
|
58
|
+
export payload. Do not fetch an unbounded result and trim it afterward.
|
|
59
|
+
|
|
60
|
+
The [bounded search/export example](examples/mcp/work-tools.mjs) implements:
|
|
61
|
+
|
|
62
|
+
- Search: at most five rows, weight `1 + 2 × returned rows`, reserve 11 units.
|
|
63
|
+
- Export: at most 256 UTF-8 payload bytes, weight `16 + payload bytes`, reserve 272.
|
|
64
|
+
Transport framing/JSON encoding are not included in that payload-byte allowance.
|
|
65
|
+
- Forbidden admin: application authorization denies before any callback.
|
|
66
|
+
|
|
67
|
+
The synthetic source contains fixed small records. A real data source must enforce
|
|
68
|
+
its own database scan, CPU/time and storage-read bounds too; a row limit is not a
|
|
69
|
+
claim that a query scans only those rows. The separate weights use separate rules,
|
|
70
|
+
not interchangeable token or monetary estimates.
|
|
71
|
+
|
|
72
|
+
Run `node examples/mcp/start-work.mjs` after configuring the existing Auth0 example
|
|
73
|
+
variables and `WEBDECOY_URL`, `WEBDECOY_KEY`, `WEBDECOY_PROPERTY_ID`, and
|
|
74
|
+
`WEBDECOY_SUBJECT_SECRET`. The fixture binds to loopback and makes no model calls.
|
|
75
|
+
It requires an updated runtime. Replace its single-subject allowlist with current
|
|
76
|
+
application membership for an actual customer deployment.
|
|
77
|
+
|
|
78
|
+
## Settlement, failures and retries
|
|
79
|
+
|
|
80
|
+
After a completed callback, a synchronous `measure(result, context)` may report
|
|
81
|
+
confirmed integer usage between zero and `maxUnits`. Without it, the fixed maximum
|
|
82
|
+
weight is charged. Confirmed lower usage releases the difference in the original
|
|
83
|
+
admission window. Never measure from untrusted request claims. Errors, cancellation,
|
|
84
|
+
crashes, asynchronous/invalid measurement, excess usage and missing/failed settlement
|
|
85
|
+
keep the conservative maximum charged. Successful results survive reporting or
|
|
86
|
+
settlement failure and expose unknown accounting evidence.
|
|
87
|
+
|
|
88
|
+
Unknown work is never refunded just because a lease or settlement deadline expires.
|
|
89
|
+
A hard ceiling depends on application-enforced maximum work, enforce/closed settings,
|
|
90
|
+
and bounded completion. Observe/open can execute without a confirmed reservation and
|
|
91
|
+
must not claim a ceiling. Detector fail-open is separate from work-state failure.
|
|
92
|
+
|
|
93
|
+
A reservation has a server-generated timestamp/UUID operation ID. For recovery across
|
|
94
|
+
application retries, persist an ID from `createQuotaOperationId` (exported from
|
|
95
|
+
`@webdecoy/ai-protection/fetch`) in trusted application state, and provide it through
|
|
96
|
+
`work.operationId(context)`. Do not accept an arbitrary client ID or use MCP message,
|
|
97
|
+
agent or session IDs as this key. Reuse requires the same caller/tenant/tool, bounds,
|
|
98
|
+
policy and argument binding. Conflicts deny even in open mode.
|
|
99
|
+
|
|
100
|
+
The SDK makes no automatic work-RPC or callback retries. An identical reserve replay
|
|
101
|
+
returns accounting evidence but **never grants another dispatch**; the SDK returns
|
|
102
|
+
`work_replay` (409). Lost admission replies may therefore consume capacity without
|
|
103
|
+
executing work. A changed retry returns `work_conflict` (409). Return stored business
|
|
104
|
+
results through your own idempotency layer when appropriate. Never mint a new ID to
|
|
105
|
+
retry an unknown write blindly. Application/provider idempotency remains necessary;
|
|
106
|
+
this is not an exactly-once side-effect guarantee. Identical settlement is idempotent;
|
|
107
|
+
conflicting or over-bound settlement is rejected without lowering the charge.
|
|
108
|
+
|
|
109
|
+
## Windows and evidence
|
|
110
|
+
|
|
111
|
+
- Fixed UTC-aligned windows: 1–86,400 seconds. Boundary bursts are possible; use
|
|
112
|
+
concurrency controls separately. Limits/maxima: integer units up to 10^12.
|
|
113
|
+
- IDs: ten-minute admission lifetime, at most 30 seconds future skew; expired IDs
|
|
114
|
+
remain invalid even after receipts are purged. Settlement deadline: one hour.
|
|
115
|
+
- Receipts retained through the window end plus 24 hours. Unknown charge persists
|
|
116
|
+
for its admission window; this is not a lifetime allowance or infinite ledger.
|
|
117
|
+
- At most 32 work rules and 10,000 retained operations per property, including denials.
|
|
118
|
+
Capacity failures follow state-failure policy. Existing replay works at capacity.
|
|
119
|
+
These are operational bounds, not commercial plan changes.
|
|
120
|
+
- Reports show reserved/charged units, remaining allowance at admission and
|
|
121
|
+
reserved/settled/unknown/unavailable/denied/replay status. Remaining is a snapshot,
|
|
122
|
+
not current balance. Delivery is best effort; missing reports do not imply no work.
|
|
123
|
+
Local callback completion is not independent proof of a remote side effect.
|
package/action-runtime.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {prepareWork} from './work.mjs';
|
|
1
2
|
import {prepareQuota,quotaHash} from './quota.mjs';
|
|
2
3
|
import {prepareConcurrency} from './concurrency.mjs';
|
|
3
4
|
import {validPropertyID} from './account.mjs';
|
|
@@ -20,10 +21,15 @@ export function prepareActionRuntime(options, definitions) {
|
|
|
20
21
|
const gate=prepareQuota({...c,accountQuota:{...q,idempotency:false,operationId:undefined,sessionLimit:0,subject:ctx=>subject(ctx,tenant)}});
|
|
21
22
|
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
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
const concurrencies=[];
|
|
25
|
+
for(const [key,tenant] of [['concurrency',false],['tenantConcurrency',true]])if(l[key]){
|
|
26
|
+
const option=l[key];if(ruleIDs.has(option.ruleId))throw Error('Action limits require distinct rule IDs');ruleIDs.add(option.ruleId);
|
|
27
|
+
const gate=prepareConcurrency({...c,concurrency:{...option,subject:ctx=>subject(ctx,tenant)}});
|
|
28
|
+
concurrencies.push(async(ctx,signal)=>{const r=await gate(ctx,signal);if(tenant)r.check.id='tenant_concurrency';return r;});
|
|
29
|
+
}
|
|
30
|
+
let work=null;
|
|
31
|
+
if(l.work){if(ruleIDs.has(l.work.ruleId))throw Error('Action limits require distinct rule IDs');ruleIDs.add(l.work.ruleId);work=prepareWork(c,l.work,name,options.policyVersion);}
|
|
32
|
+
limits.set(name,{gates,concurrencies,work});
|
|
27
33
|
}
|
|
28
34
|
const reporter=createReporter({reportingTimeoutMs:c.reportingTimeoutMs??1000,maxPendingReports:c.maxPendingReports??100,
|
|
29
35
|
onObservation:async(event,{signal})=>{
|
|
@@ -32,7 +38,7 @@ export function prepareActionRuntime(options, definitions) {
|
|
|
32
38
|
const payload={schema:2,request_id:event.eventId,timestamp:event.timestamp,decision:event.decision,reason:event.reason,
|
|
33
39
|
degraded:event.checks.some(c=>c.decision==='unavailable'),checks,handler_attempted:event.attempted,
|
|
34
40
|
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}};
|
|
41
|
+
tool_action:{action_id:event.actionId,name:event.action,policy_version:event.policyVersion,outcome:event.outcome,...(event.work?{work:event.work}:{})}};
|
|
36
42
|
const response=await fetch(new URL('/api/v1/sdk/ai-abuse/reports',url),{method:'POST',redirect:'error',signal,
|
|
37
43
|
headers:{Authorization:`Bearer ${c.webdecoyKey}`,'X-WebDecoy-Property-ID':c.propertyId,'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
38
44
|
await response.body?.cancel();if(!response.ok)throw Error('Action reporting unavailable');
|
package/actions.d.mts
CHANGED
|
@@ -13,11 +13,27 @@ export interface TrustedCaller {
|
|
|
13
13
|
}
|
|
14
14
|
export type ActionInput = null | boolean | number | string | readonly ActionInput[] | {readonly [key:string]: ActionInput};
|
|
15
15
|
export interface ActionContext {
|
|
16
|
+
readonly work?: {readonly maxUnits:number};
|
|
16
17
|
readonly caller: TrustedCaller;
|
|
17
18
|
readonly args: ActionInput;
|
|
18
19
|
readonly signal?: AbortSignal;
|
|
19
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
|
+
}
|
|
20
35
|
export interface ActionEvent {
|
|
36
|
+
readonly work?:ActionWorkEvidence;
|
|
21
37
|
readonly schema: 1;
|
|
22
38
|
readonly eventId: string;
|
|
23
39
|
readonly timestamp: string;
|
|
@@ -33,6 +49,8 @@ export interface ActionEvent {
|
|
|
33
49
|
}
|
|
34
50
|
export interface ActionQuota {ruleId:string;limit:number;windowSeconds:number;mode?:'observe'|'enforce';failureMode?:'open'|'closed';timeoutMs?:number}
|
|
35
51
|
export interface ActionLimits {
|
|
52
|
+
tenantConcurrency?: ActionLimits['concurrency'];
|
|
53
|
+
work?:ActionWork;
|
|
36
54
|
callerQuota?: ActionQuota;
|
|
37
55
|
tenantQuota?: ActionQuota;
|
|
38
56
|
concurrency?: {ruleId:string;accountLimit:number;featureLimit:number;mode?:'observe'|'enforce';failureMode?:'open'|'closed';ttlSeconds?:number;maxSeconds?:number;timeoutMs?:number};
|
package/actions.mjs
CHANGED
|
@@ -59,7 +59,7 @@ function inputSnapshot(input) {
|
|
|
59
59
|
return result;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/**
|
|
62
|
+
/** Protected dispatch with local permissions and optional shared controls. Never retries execution. */
|
|
63
63
|
export function createActionProtection(options) {
|
|
64
64
|
if (!options || typeof options.authenticate !== 'function' || (typeof options.policyVersion !== 'string' || !token.test(options.policyVersion))) throw Error('Invalid action configuration');
|
|
65
65
|
const authenticate = options.authenticate, policyVersion = options.policyVersion, sink = options.onEvent;
|
|
@@ -81,7 +81,8 @@ export function createActionProtection(options) {
|
|
|
81
81
|
const actionId = randomUUID();
|
|
82
82
|
// Unknown caller-controlled action strings are never placed in evidence.
|
|
83
83
|
const action = actions.get(name), eventAction = action ? name : 'unregistered';
|
|
84
|
-
let attempted = false,completed=false,lease;
|
|
84
|
+
let attempted = false,completed=false,lease,work;
|
|
85
|
+
const leases=[];
|
|
85
86
|
const checks=[];
|
|
86
87
|
const deadline = new AbortController();
|
|
87
88
|
const admissionSignal = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal;
|
|
@@ -90,7 +91,7 @@ export function createActionProtection(options) {
|
|
|
90
91
|
function emit(decision, reason, outcome) {
|
|
91
92
|
|
|
92
93
|
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
|
+
evaluation:'local', decision, reason, attempted, outcome,...(work?{work:Object.freeze({...work.evidence})}:{}),checks:Object.freeze(checks.map(c=>Object.freeze({...c})))});
|
|
94
95
|
if(runtime)void runtime.report(event);
|
|
95
96
|
if(!sink||pendingEvents>=100)return;
|
|
96
97
|
pendingEvents++;
|
|
@@ -131,38 +132,44 @@ export function createActionProtection(options) {
|
|
|
131
132
|
const r=await gate(context,admissionSignal);checks.push(r.check);
|
|
132
133
|
if(r.denial)deny(r.denial.reason,r.denial.status,r.denial.retryAfterSeconds);
|
|
133
134
|
}
|
|
134
|
-
|
|
135
|
-
lease=await
|
|
135
|
+
for(const gate of controls?.concurrencies??[]){
|
|
136
|
+
lease=await gate(context,lease?.signal??admissionSignal);leases.push(lease);checks.push(lease.check);
|
|
136
137
|
if(lease.denial)deny(lease.denial.reason,lease.denial.status,lease.denial.retryAfterSeconds);
|
|
137
138
|
lease.signal.throwIfAborted();
|
|
138
139
|
}
|
|
140
|
+
if(controls?.work){
|
|
141
|
+
work=await controls.work(context,lease?.signal??admissionSignal);checks.push(work.check);
|
|
142
|
+
if(work.denial)deny(work.denial.reason,work.denial.status,work.denial.retryAfterSeconds);
|
|
143
|
+
}
|
|
139
144
|
// Authentication may expire while ownership/policy checks are running.
|
|
140
145
|
if (caller.expiresAt <= Date.now()) deny('authentication_expired',401);
|
|
141
146
|
cancelled();
|
|
142
147
|
clearTimeout(timer);
|
|
143
148
|
attempted = true;
|
|
144
149
|
emit('allow','authorized','attempted');
|
|
145
|
-
const execution=Object.freeze({...context,signal:lease?.signal??context.signal});
|
|
150
|
+
const execution=Object.freeze({...context,...(work?{work:work.bound}:{}),signal:lease?.signal??context.signal});
|
|
146
151
|
const result = await action.execute(execution);
|
|
147
152
|
execution.signal.throwIfAborted();
|
|
148
153
|
// Resolution is application completion, not proof of an external side effect.
|
|
149
154
|
cancelled();
|
|
150
155
|
completed=true;
|
|
151
|
-
if(
|
|
156
|
+
if(work){const check=await work.finish(result,true);if(check)checks.push(check);}
|
|
157
|
+
for(const lease of [...leases].reverse())if(!lease.denial){
|
|
152
158
|
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});}
|
|
159
|
+
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});}
|
|
154
160
|
}
|
|
155
161
|
emit('allow','authorized','completed');
|
|
156
162
|
return result;
|
|
157
163
|
} catch (error) {
|
|
158
164
|
if (!attempted && deadline.signal.aborted && !signal?.aborted) deny('admission_timeout',503);
|
|
165
|
+
if(work&&!completed)await work.finish(undefined,false);
|
|
159
166
|
if (attempted) emit('allow',signal?.aborted ? 'execution_cancelled' : 'execution_failed','unknown');
|
|
160
167
|
else if (!(error instanceof ActionDenied)) emit('deny','admission_cancelled','not_attempted');
|
|
161
168
|
throw error;
|
|
162
169
|
} finally {
|
|
163
170
|
clearTimeout(timer);
|
|
164
171
|
// Only confirmed completion (or no callback) releases; uncertain work holds.
|
|
165
|
-
if(lease
|
|
172
|
+
for(const lease of [...leases].reverse())if(!lease.denial)await lease.finish(!attempted||completed).catch(()=>{});
|
|
166
173
|
}
|
|
167
174
|
}});
|
|
168
175
|
}
|
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.5",
|
|
14
14
|
"exports": {
|
|
15
15
|
"./fetch": {
|
|
16
16
|
"types": "./fetch.d.mts",
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"./actions": {
|
|
28
28
|
"types": "./actions.d.mts",
|
|
29
29
|
"import": "./actions.mjs"
|
|
30
|
+
},
|
|
31
|
+
"./mcp": {
|
|
32
|
+
"types": "./mcp.d.mts",
|
|
33
|
+
"import": "./mcp.mjs"
|
|
30
34
|
}
|
|
31
35
|
},
|
|
32
36
|
"files": [
|
|
@@ -53,7 +57,12 @@
|
|
|
53
57
|
"NOTICE",
|
|
54
58
|
"actions.mjs",
|
|
55
59
|
"actions.d.mts",
|
|
56
|
-
"action-runtime.mjs"
|
|
60
|
+
"action-runtime.mjs",
|
|
61
|
+
"mcp.mjs",
|
|
62
|
+
"mcp.d.mts",
|
|
63
|
+
"MCP.md",
|
|
64
|
+
"work.mjs",
|
|
65
|
+
"WORK.md"
|
|
57
66
|
],
|
|
58
67
|
"description": "Bot and abuse protection for AI-powered applications. Server-side request admission for Node.js.",
|
|
59
68
|
"license": "Apache-2.0",
|
|
@@ -77,6 +86,16 @@
|
|
|
77
86
|
"webdecoy"
|
|
78
87
|
],
|
|
79
88
|
"devDependencies": {
|
|
80
|
-
"typescript": "6.0.3"
|
|
89
|
+
"typescript": "6.0.3",
|
|
90
|
+
"@modelcontextprotocol/sdk": "1.31.0",
|
|
91
|
+
"@types/node": "22.19.15"
|
|
92
|
+
},
|
|
93
|
+
"peerDependencies": {
|
|
94
|
+
"@modelcontextprotocol/sdk": "1.31.0"
|
|
95
|
+
},
|
|
96
|
+
"peerDependenciesMeta": {
|
|
97
|
+
"@modelcontextprotocol/sdk": {
|
|
98
|
+
"optional": true
|
|
99
|
+
}
|
|
81
100
|
}
|
|
82
101
|
}
|
package/work.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {createQuotaOperationId,quotaHash} from './quota.mjs';
|
|
2
|
+
import {readJSON} from './transport.mjs';
|
|
3
|
+
const code=/^[a-z][a-z0-9_]{0,63}$/;
|
|
4
|
+
const integer=n=>Number.isSafeInteger(n)&&n>=0&&n<=1e12;
|
|
5
|
+
const canonical=value=>JSON.stringify(value===null||typeof value!=='object'?value:Array.isArray(value)?value.map(v=>JSON.parse(canonical(v))):Object.fromEntries(Object.keys(value).sort().map(k=>[k,JSON.parse(canonical(value[k]))])));
|
|
6
|
+
export function prepareWork(config,definition,name,policyVersion){
|
|
7
|
+
const w={mode:'observe',failureMode:'open',timeoutMs:1000,...definition};
|
|
8
|
+
const limits=Object.fromEntries(['caller','tenant','tool'].map(k=>[k,w.limits?.[k]??0]));
|
|
9
|
+
if(!code.test(w.ruleId??'')||!integer(w.maxUnits)||w.maxUnits<1||!integer(w.windowSeconds)||w.windowSeconds<1||w.windowSeconds>86400||!integer(w.timeoutMs)||w.timeoutMs<1||w.timeoutMs>10000||!['observe','enforce'].includes(w.mode)||!['open','closed'].includes(w.failureMode)||!Object.values(limits).every(integer)||!Object.values(limits).some(n=>n>0)||(w.measure!==undefined&&typeof w.measure!=='function')||(w.operationId!==undefined&&typeof w.operationId!=='function'))throw Error('Invalid tool work policy');
|
|
10
|
+
async function rpc(body,signal){
|
|
11
|
+
const response=await fetch(new URL('/api/v1/sdk/ai-abuse/work',config.webdecoyUrl),{method:'POST',redirect:'error',signal:AbortSignal.any([signal??new AbortController().signal,AbortSignal.timeout(w.timeoutMs)]),headers:{Authorization:`Bearer ${config.webdecoyKey}`,'X-WebDecoy-Property-ID':config.propertyId,'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
12
|
+
if(!response.ok){await response.body?.cancel();const e=Error('Work unavailable');e.status=response.status;throw e;}
|
|
13
|
+
const r=await readJSON(response,2048);
|
|
14
|
+
if(r?.schema!==1||!['allowed','granted','replay','settled'].every(k=>typeof r[k]==='boolean')||!['reserved_units','charged_units','remaining_units','retry_after_seconds'].every(k=>integer(r[k]))||r.retry_after_seconds>86400||!code.test(r.reason??''))throw Error('Invalid work response');
|
|
15
|
+
return r;
|
|
16
|
+
}
|
|
17
|
+
return async(ctx,signal)=>{
|
|
18
|
+
let operationId=w.operationId?w.operationId(ctx):createQuotaOperationId();
|
|
19
|
+
if(operationId&&typeof operationId.then==='function')Promise.resolve(operationId).catch(()=>{});
|
|
20
|
+
if(typeof operationId!=='string'||! /^[1-9][0-9]{9}\.[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(operationId))throw Error('Invalid trusted work operation ID');
|
|
21
|
+
const body={schema:1,operation:'reserve',operation_id:operationId,rule_id:w.ruleId,mode:w.mode,window_seconds:w.windowSeconds,limits,units:w.maxUnits,
|
|
22
|
+
subject:quotaHash(config.subjectSecret,'webdecoy.work.caller.v1',ctx.caller.issuer,ctx.caller.tenant,ctx.caller.subject),tenant:quotaHash(config.subjectSecret,'webdecoy.work.tenant.v1',ctx.caller.issuer,ctx.caller.tenant),binding:quotaHash(config.subjectSecret,'webdecoy.work.arguments.v1',name,policyVersion,canonical(ctx.args))};
|
|
23
|
+
const evidence={rule_id:w.ruleId,mode:w.mode,status:'unknown',reserved_units:w.maxUnits};
|
|
24
|
+
const check={id:'tool_work',source:'shared',mode:w.mode,decision:'allow',reason:'work_allowed',durationMs:0};
|
|
25
|
+
const began=performance.now();let grant,denial;
|
|
26
|
+
try{
|
|
27
|
+
grant=await rpc(body,signal);
|
|
28
|
+
if(grant.replay){denial={reason:'work_replay',status:409};evidence.status='replay';}
|
|
29
|
+
else if(!grant.granted){if(grant.reason!=='work_exceeded')throw Error('Invalid work denial');denial={reason:'work_exceeded',status:429,retryAfterSeconds:grant.retry_after_seconds};evidence.status='denied';}
|
|
30
|
+
else {if(!['work_allowed','work_exceeded'].includes(grant.reason)||(!grant.allowed&&w.mode==='enforce')||grant.reserved_units!==w.maxUnits||grant.charged_units!==w.maxUnits)throw Error('Invalid work grant');evidence.status='reserved';}
|
|
31
|
+
evidence.reserved_units=grant.reserved_units;evidence.charged_units=grant.charged_units;evidence.remaining_units=grant.remaining_units;
|
|
32
|
+
check.reason=grant.reason;check.decision=grant.allowed&&!grant.replay?'allow':'deny';
|
|
33
|
+
}catch(e){
|
|
34
|
+
grant=undefined;
|
|
35
|
+
signal?.throwIfAborted();check.decision='unavailable';check.reason='work_outcome_unknown';evidence.status='unavailable';
|
|
36
|
+
if([400,403,409,410].includes(e.status)){denial={reason:e.status===409?'work_conflict':'work_operation_rejected',status:e.status};check.decision='deny';check.reason=denial.reason;}
|
|
37
|
+
else if(w.mode==='enforce'&&w.failureMode==='closed')denial={reason:'work_outcome_unknown',status:503};
|
|
38
|
+
}
|
|
39
|
+
check.durationMs=performance.now()-began;
|
|
40
|
+
return {check,denial,evidence,bound:Object.freeze({maxUnits:w.maxUnits}),async finish(result,completed){
|
|
41
|
+
if(!grant?.granted||grant.replay)return;
|
|
42
|
+
if(!completed){evidence.status='unknown';return;}
|
|
43
|
+
try{
|
|
44
|
+
const units=w.measure?w.measure(result,ctx):w.maxUnits;
|
|
45
|
+
if(units&&typeof units.then==='function')Promise.resolve(units).catch(()=>{});
|
|
46
|
+
if(!integer(units)||units>w.maxUnits)throw Error('Unconfirmed or excess work');
|
|
47
|
+
const settled=await rpc({...body,operation:'settle',units});
|
|
48
|
+
if(!settled.settled||settled.reason!=='work_settled'||settled.charged_units!==units)throw Error('Work settlement unavailable');
|
|
49
|
+
evidence.status='settled';evidence.charged_units=units;
|
|
50
|
+
}catch{evidence.status='unknown';return {id:'tool_work_settlement',source:'shared',mode:w.mode,decision:'unavailable',reason:'work_usage_unknown',durationMs:0};}
|
|
51
|
+
}};
|
|
52
|
+
};
|
|
53
|
+
}
|