@ultimat3/mcp 23.0.0 → 25.0.0
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/CLAUDE.md +54 -51
- package/README.md +153 -21
- package/package.json +12 -11
- package/src/app-tool.ts +7 -8
- package/src/app-tools.ts +38 -13
- package/src/audit-hook.ts +108 -0
- package/src/audit.ts +17 -0
- package/src/caller-context.ts +2 -4
- package/src/confirmation-decide.ts +116 -0
- package/src/confirmation-errors.ts +137 -0
- package/src/confirmation-gate.ts +101 -0
- package/src/confirmation-postgres.ts +143 -0
- package/src/confirmation-schema.ts +34 -0
- package/src/confirmation-store.ts +136 -0
- package/src/confirmations.ts +147 -0
- package/src/dev-host.ts +4 -4
- package/src/errors-transport.ts +94 -0
- package/src/errors.ts +44 -102
- package/src/framework-error.ts +31 -3
- package/src/from-action.ts +30 -58
- package/src/index.ts +58 -19
- package/src/list-params.ts +9 -4
- package/src/meta-call.ts +5 -4
- package/src/meta-surface.ts +86 -19
- package/src/mount-errors.ts +1 -1
- package/src/oauth-metadata.ts +1 -1
- package/src/projectable.ts +44 -40
- package/src/readonly-sql-calls.ts +106 -0
- package/src/readonly-sql.ts +26 -97
- package/src/registry.ts +6 -5
- package/src/scopes.ts +15 -4
- package/src/server-voice.ts +1 -1
- package/src/server.ts +38 -36
- package/src/tool-thrown.ts +5 -3
- package/src/transport-http.ts +119 -17
- package/src/transport-stdio.ts +1 -1
- package/src/type-pins.ts +52 -0
- package/src/validate-args.ts +12 -4
- package/src/wire.ts +21 -40
- package/src/input-schema.ts +0 -140
package/src/scopes.ts
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
// other is its authz rule, and both belong beside the code they guard. A SCOPE is neither.
|
|
5
5
|
// It is a capability of the CONNECTION — what the token was issued to do — so it is grouped
|
|
6
6
|
// here, once per scope, naming the tools that capability covers. That is also why
|
|
7
|
-
// `
|
|
7
|
+
// `toolFrom` never invents one: a projection cannot know what a token means.
|
|
8
8
|
//
|
|
9
9
|
// Without this map the second outcome is unreachable for a generated app. `ToolRegistry`
|
|
10
10
|
// enforces `scope`, the framework's own dev tools declare one, and until 2026-08 nothing an
|
|
11
11
|
// app could write ever set the field — an enforced gate no app declaration could engage.
|
|
12
12
|
|
|
13
|
-
import { McpScopeConflictError, McpScopeUnknownError } from './errors';
|
|
13
|
+
import { McpScopeConflictError, McpScopeUncoveredError, McpScopeUnknownError } from './errors';
|
|
14
14
|
import type { AnyMcpTool } from './registry';
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -25,14 +25,19 @@ export type McpScopes = Readonly<Record<string, readonly string[]>>;
|
|
|
25
25
|
/**
|
|
26
26
|
* Attach each declared scope to the tool it covers, refusing anything ambiguous at BOOT.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
28
|
+
* Three refusals, because each silently ships an ungated tool otherwise:
|
|
29
29
|
*
|
|
30
30
|
* - a name no tool in the catalog answers to (`X_MCP_SCOPE_UNKNOWN`) — a typo, or a primitive
|
|
31
31
|
* that was renamed or never listed. Skipping it leaves the tool reachable with no scope at
|
|
32
32
|
* all, which is the opposite of what the author wrote;
|
|
33
33
|
* - one tool claimed by two scopes (`X_MCP_SCOPE_CONFLICT`) — a tool carries ONE scope, so
|
|
34
34
|
* the second claim would either overwrite the first or be dropped, and which one wins would
|
|
35
|
-
* be decided by object key order
|
|
35
|
+
* be decided by object key order;
|
|
36
|
+
* - a projected tool no scope names (`X_MCP_SCOPE_UNCOVERED`). The registry gates a tool only
|
|
37
|
+
* when it carries a scope, so an unnamed one answered EVERY token — while `bearerMount`, handed
|
|
38
|
+
* the same map, serves an unnamed primitive to nobody. Giving a map is declaring the cut; a
|
|
39
|
+
* tool outside it is a mistake, not a public tool. A tool that arrived with its own `scope`
|
|
40
|
+
* (a ready `McpTool`) is covered by it.
|
|
36
41
|
*
|
|
37
42
|
* Runs AFTER duplicate names are refused, so a name resolves to exactly one tool.
|
|
38
43
|
*/
|
|
@@ -64,6 +69,12 @@ export function withScopes(
|
|
|
64
69
|
}
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
const uncovered = tools
|
|
73
|
+
.filter((tool) => tool.scope === undefined && !required.has(tool.name))
|
|
74
|
+
.map((tool) => tool.name)
|
|
75
|
+
.sort();
|
|
76
|
+
if (uncovered.length > 0) throw new McpScopeUncoveredError({ uncovered });
|
|
77
|
+
|
|
67
78
|
return tools.map((tool) => {
|
|
68
79
|
const scope = required.get(tool.name);
|
|
69
80
|
return scope === undefined ? tool : { ...tool, scope };
|
package/src/server-voice.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { formatIssues } from '@ultimat3/schema';
|
|
|
8
8
|
import type { McpCaller, McpToolResult } from './registry';
|
|
9
9
|
import type { ArgIssue } from './validate-args';
|
|
10
10
|
|
|
11
|
-
/** See `
|
|
11
|
+
/** See `McpServerInput.instructions`. */
|
|
12
12
|
export type McpInstructions = string | ((caller: McpCaller) => string | undefined);
|
|
13
13
|
|
|
14
14
|
/** The server's per-caller wording: `initialize`'s advice and the audience of every `fix:` line. */
|
package/src/server.ts
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
// and an already-resolved caller, and returns a response or `null` for a notification.
|
|
4
4
|
// Both transports (http, stdio) and every test drive this one function.
|
|
5
5
|
|
|
6
|
-
import type { ErrorAudience } from '@ultimat3/core';
|
|
6
|
+
import type { Clock, ErrorAudience } from '@ultimat3/core';
|
|
7
7
|
import { fixFor } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
8
|
+
import { outcomeForResult } from './audit';
|
|
9
|
+
import type { McpAuditHook, McpAuditor } from './audit-hook';
|
|
10
|
+
import { LOG_ONLY_AUDITOR, mcpAuditor } from './audit-hook';
|
|
9
11
|
import { McpProtocolError, McpScopeDeniedError, TOOL_UNKNOWN_FIX } from './errors';
|
|
10
|
-
import { asFrameworkError } from './framework-error';
|
|
12
|
+
import { asFrameworkError, forAudience } from './framework-error';
|
|
11
13
|
import { metaCall } from './meta-call';
|
|
12
14
|
import { META_UNKNOWN_FIX } from './meta-errors';
|
|
13
15
|
import type { McpResourceGroups, McpSurfaceOption, MetaResource } from './meta-surface';
|
|
@@ -27,7 +29,7 @@ import { ResourceRegistry } from './resources';
|
|
|
27
29
|
import type { McpInstructions, McpServerVoice } from './server-voice';
|
|
28
30
|
import { instructionsFor, invalidArgsResult } from './server-voice';
|
|
29
31
|
import { admitted, thrownResponse } from './tool-thrown';
|
|
30
|
-
import type { JsonRpcId, JsonRpcRequest, JsonRpcResponse, ServerInfo } from './wire';
|
|
32
|
+
import type { JsonRpcId, JsonRpcRequest, JsonRpcResponse, McpWire, ServerInfo } from './wire';
|
|
31
33
|
import {
|
|
32
34
|
defaultServerInfo,
|
|
33
35
|
errorResponse,
|
|
@@ -38,28 +40,14 @@ import {
|
|
|
38
40
|
isNotification,
|
|
39
41
|
MCP_PROTOCOL_VERSION,
|
|
40
42
|
METHOD_NOT_FOUND,
|
|
43
|
+
messageUnitOf,
|
|
41
44
|
paramsOf,
|
|
42
45
|
resultResponse,
|
|
43
46
|
} from './wire';
|
|
44
47
|
|
|
45
|
-
|
|
46
|
-
* Which wire one message arrived on, so a refusal can name the unit THAT transport counts in.
|
|
47
|
-
* `handle` is transport-independent and stays so — this is a hint for the wording of one fix, never
|
|
48
|
-
* a branch in dispatch. HTTP carries its mounted path because `mcpHttpRoute({ path })` is a knob:
|
|
49
|
-
* a batch refusal that told every client to retry `POST /mcp` was wrong for an app mounted at
|
|
50
|
-
* `/app-mcp`, which is how this came to be threaded rather than spelled.
|
|
51
|
-
*/
|
|
52
|
-
export type McpWire =
|
|
53
|
-
| { readonly transport: 'http'; readonly path: string }
|
|
54
|
-
| { readonly transport: 'stdio' };
|
|
55
|
-
|
|
56
|
-
/** The unit a transport counts one request in — or the neutral word when no wire said. */
|
|
57
|
-
const messageUnitOf = (wire: McpWire | undefined): string => {
|
|
58
|
-
if (wire === undefined) return 'message';
|
|
59
|
-
return wire.transport === 'http' ? `POST ${wire.path}` : 'line';
|
|
60
|
-
};
|
|
48
|
+
export type { McpWire } from './wire';
|
|
61
49
|
|
|
62
|
-
export interface
|
|
50
|
+
export interface McpServerInput {
|
|
63
51
|
readonly tools?: readonly AnyMcpTool[];
|
|
64
52
|
readonly resources?: readonly McpResource[];
|
|
65
53
|
readonly prompts?: readonly McpPrompt[];
|
|
@@ -82,6 +70,10 @@ export interface CreateMcpServerInput {
|
|
|
82
70
|
* serves `'caller'`.
|
|
83
71
|
*/
|
|
84
72
|
readonly errorAudience?: ErrorAudience | undefined;
|
|
73
|
+
/** Every gate decision as data, after its log line — see `audit-hook.ts`. The route reads it too. */
|
|
74
|
+
readonly onAudit?: McpAuditHook | undefined;
|
|
75
|
+
/** Stamps each `onAudit` event's `at`. */
|
|
76
|
+
readonly clock?: Clock | undefined;
|
|
85
77
|
}
|
|
86
78
|
|
|
87
79
|
/** The set of JSON-RPC methods this server answers. Kept in sync with `classify`. */
|
|
@@ -96,7 +88,7 @@ const METHODS = [
|
|
|
96
88
|
'prompts/get',
|
|
97
89
|
] as const;
|
|
98
90
|
|
|
99
|
-
export function
|
|
91
|
+
export function mcpServer(input: McpServerInput = {}): McpServer {
|
|
100
92
|
const tools = new ToolRegistry().registerAll(input.tools ?? []);
|
|
101
93
|
const resources = new ResourceRegistry().registerAll(input.resources ?? []);
|
|
102
94
|
const meta = MetaSurface.build({
|
|
@@ -111,6 +103,9 @@ export function createMcpServer(input: CreateMcpServerInput = {}): McpServer {
|
|
|
111
103
|
input.serverInfo ?? defaultServerInfo(),
|
|
112
104
|
meta,
|
|
113
105
|
{ instructions: input.instructions, errorAudience: input.errorAudience ?? 'developer' },
|
|
106
|
+
input.onAudit === undefined && input.clock === undefined
|
|
107
|
+
? LOG_ONLY_AUDITOR
|
|
108
|
+
: mcpAuditor({ onAudit: input.onAudit, clock: input.clock }),
|
|
114
109
|
);
|
|
115
110
|
}
|
|
116
111
|
|
|
@@ -122,6 +117,8 @@ export class McpServer {
|
|
|
122
117
|
/** `undefined` for a flat-only server — every existing app, byte for byte. */
|
|
123
118
|
private readonly meta: MetaSurface | undefined;
|
|
124
119
|
private readonly voice: McpServerVoice;
|
|
120
|
+
/** Where every decision is audited — the log line, then `onAudit`. Public for the HTTP route. */
|
|
121
|
+
readonly audit: McpAuditor;
|
|
125
122
|
|
|
126
123
|
constructor(
|
|
127
124
|
tools: ToolRegistry,
|
|
@@ -130,6 +127,7 @@ export class McpServer {
|
|
|
130
127
|
serverInfo: ServerInfo,
|
|
131
128
|
meta?: MetaSurface,
|
|
132
129
|
voice: McpServerVoice = { errorAudience: 'developer' },
|
|
130
|
+
audit: McpAuditor = LOG_ONLY_AUDITOR,
|
|
133
131
|
) {
|
|
134
132
|
this.tools = tools;
|
|
135
133
|
this.resources = resources;
|
|
@@ -137,6 +135,7 @@ export class McpServer {
|
|
|
137
135
|
this.serverInfo = serverInfo;
|
|
138
136
|
this.meta = meta;
|
|
139
137
|
this.voice = voice;
|
|
138
|
+
this.audit = audit;
|
|
140
139
|
}
|
|
141
140
|
|
|
142
141
|
async handle(
|
|
@@ -275,6 +274,7 @@ export class McpServer {
|
|
|
275
274
|
const answered = await metaCall(
|
|
276
275
|
{
|
|
277
276
|
tools: this.tools,
|
|
277
|
+
audit: this.audit,
|
|
278
278
|
dispatch: (at, tool, resolved, who) => this.dispatch(at, tool, resolved, who),
|
|
279
279
|
notFound: (at, tool, who, fix) => this.notFound(at, tool, who, fix),
|
|
280
280
|
},
|
|
@@ -293,7 +293,7 @@ export class McpServer {
|
|
|
293
293
|
|
|
294
294
|
/** OUTCOME 1, the one answer for absent and hidden alike. See `dispatch`. */
|
|
295
295
|
private notFound(id: JsonRpcId, name: string, caller: McpCaller, fix: string): JsonRpcResponse {
|
|
296
|
-
|
|
296
|
+
this.audit.toolCall({ tool: name, outcome: 'hidden', caller, code: 'X_MCP_TOOL_UNKNOWN' });
|
|
297
297
|
return errorResponse(id, METHOD_NOT_FOUND, `tool not found: ${name} — ${fix}`);
|
|
298
298
|
}
|
|
299
299
|
|
|
@@ -327,7 +327,7 @@ export class McpServer {
|
|
|
327
327
|
// nothing — and the fix travels with it, built by the error that owns the wording.
|
|
328
328
|
case 'scope-denied': {
|
|
329
329
|
const denial = new McpScopeDeniedError({ name, scope: resolved.scope });
|
|
330
|
-
|
|
330
|
+
this.audit.toolCall({
|
|
331
331
|
tool: name,
|
|
332
332
|
outcome: 'scope-denied',
|
|
333
333
|
caller,
|
|
@@ -346,9 +346,10 @@ export class McpServer {
|
|
|
346
346
|
// they send gets the denial `handle` would give, never the issue list — which describes
|
|
347
347
|
// the arguments of a tool they may not call.
|
|
348
348
|
const refused = admitted(resolved.tool, caller);
|
|
349
|
-
if (refused !== undefined)
|
|
349
|
+
if (refused !== undefined)
|
|
350
|
+
return thrownResponse(id, name, refused, caller, audience, this.audit);
|
|
350
351
|
const invalid = invalidArgsResult(name, resolved.issues, audience);
|
|
351
|
-
|
|
352
|
+
this.audit.toolCall({ tool: name, outcome: 'invalid-args', caller, code: invalid.code });
|
|
352
353
|
return resultResponse(id, invalid.result);
|
|
353
354
|
}
|
|
354
355
|
case 'ok':
|
|
@@ -359,13 +360,13 @@ export class McpServer {
|
|
|
359
360
|
try {
|
|
360
361
|
result = await resolved.tool.handle(resolved.args, caller);
|
|
361
362
|
} catch (error) {
|
|
362
|
-
return thrownResponse(id, name, error, caller, audience);
|
|
363
|
+
return thrownResponse(id, name, error, caller, audience, this.audit);
|
|
363
364
|
}
|
|
364
365
|
|
|
365
366
|
// A tool may answer `isError` itself (admin renders its own denial). Outcome 3 unless it
|
|
366
367
|
// NAMED the code it refused with, in which case the same classifier a thrown error goes
|
|
367
368
|
// through decides — a tool's own argument check is not a denial a prober drove.
|
|
368
|
-
|
|
369
|
+
this.audit.toolCall({
|
|
369
370
|
tool: name,
|
|
370
371
|
outcome: outcomeForResult(result),
|
|
371
372
|
caller,
|
|
@@ -406,7 +407,7 @@ export class McpServer {
|
|
|
406
407
|
// `code` on the audit line either, because the wire carries none — the tool surface's
|
|
407
408
|
// `X_MCP_TOOL_UNKNOWN` is an error class this branch has no twin for.
|
|
408
409
|
case 'not-found':
|
|
409
|
-
|
|
410
|
+
this.audit.resourceRead({ uri, outcome: 'hidden', caller });
|
|
410
411
|
return errorResponse(id, METHOD_NOT_FOUND, `resource not found: ${uri}`);
|
|
411
412
|
// OUTCOME 2. The caller can already see this resource, so naming the missing scope leaks
|
|
412
413
|
// nothing — and the fix travels with it, built by the error that owns the wording.
|
|
@@ -416,7 +417,7 @@ export class McpServer {
|
|
|
416
417
|
scope: resolved.scope,
|
|
417
418
|
subject: 'resource',
|
|
418
419
|
});
|
|
419
|
-
|
|
420
|
+
this.audit.resourceRead({
|
|
420
421
|
uri,
|
|
421
422
|
outcome: 'scope-denied',
|
|
422
423
|
caller,
|
|
@@ -444,15 +445,16 @@ export class McpServer {
|
|
|
444
445
|
if (contents === undefined) {
|
|
445
446
|
// The resolver said `ok` and the registry then had nothing: a bug here, not a walk, so it
|
|
446
447
|
// is `failed` in the log while the caller still gets the same not-found it would have.
|
|
447
|
-
|
|
448
|
+
this.audit.resourceRead({ uri, outcome: 'failed', caller });
|
|
448
449
|
return errorResponse(id, METHOD_NOT_FOUND, `resource not found: ${uri}`);
|
|
449
450
|
}
|
|
450
|
-
|
|
451
|
+
this.audit.resourceRead({ uri, outcome: 'ok', caller });
|
|
451
452
|
return resultResponse(id, { contents: [contents] });
|
|
452
453
|
} catch (error) {
|
|
453
|
-
const
|
|
454
|
-
if (
|
|
455
|
-
|
|
454
|
+
const thrown = asFrameworkError(error);
|
|
455
|
+
if (thrown !== undefined) {
|
|
456
|
+
const framework = forAudience(thrown, this.voice.errorAudience);
|
|
457
|
+
this.audit.resourceRead({ uri, outcome: 'failed', caller, code: framework.code });
|
|
456
458
|
return errorResponse(id, INTERNAL_ERROR, `resource "${uri}" could not be read`, {
|
|
457
459
|
code: framework.code,
|
|
458
460
|
cause: framework.cause,
|
|
@@ -462,7 +464,7 @@ export class McpServer {
|
|
|
462
464
|
}
|
|
463
465
|
// No internals: a provider's own message names a path, a query or a host the caller has no
|
|
464
466
|
// business seeing, exactly as a failing tool's does.
|
|
465
|
-
|
|
467
|
+
this.audit.resourceRead({ uri, outcome: 'failed', caller });
|
|
466
468
|
return errorResponse(id, INTERNAL_ERROR, `resource "${uri}" could not be read`);
|
|
467
469
|
}
|
|
468
470
|
}
|
package/src/tool-thrown.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// raised it. Split from `server.ts` for the file ceiling; the server is its only caller.
|
|
4
4
|
|
|
5
5
|
import type { ErrorAudience } from '@ultimat3/core';
|
|
6
|
-
import {
|
|
6
|
+
import { outcomeForCode } from './audit';
|
|
7
|
+
import type { McpAuditor } from './audit-hook';
|
|
7
8
|
import { asFrameworkError, renderFrameworkError } from './framework-error';
|
|
8
9
|
import type { AnyMcpTool, McpCaller } from './registry';
|
|
9
10
|
import type { JsonRpcId, JsonRpcResponse } from './wire';
|
|
@@ -21,10 +22,11 @@ export function thrownResponse(
|
|
|
21
22
|
error: unknown,
|
|
22
23
|
caller: McpCaller,
|
|
23
24
|
audience: ErrorAudience,
|
|
25
|
+
audit: McpAuditor,
|
|
24
26
|
): JsonRpcResponse {
|
|
25
27
|
const framework = asFrameworkError(error);
|
|
26
28
|
if (framework !== undefined) {
|
|
27
|
-
|
|
29
|
+
audit.toolCall({
|
|
28
30
|
tool: name,
|
|
29
31
|
outcome: outcomeForCode(framework.code),
|
|
30
32
|
caller,
|
|
@@ -35,7 +37,7 @@ export function thrownResponse(
|
|
|
35
37
|
isError: true,
|
|
36
38
|
});
|
|
37
39
|
}
|
|
38
|
-
|
|
40
|
+
audit.toolCall({ tool: name, outcome: 'failed', caller });
|
|
39
41
|
return errorResponse(id, INTERNAL_ERROR, `tool "${name}" failed unexpectedly`);
|
|
40
42
|
}
|
|
41
43
|
|
package/src/transport-http.ts
CHANGED
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
|
|
22
22
|
import type { Actor, Clock } from '@ultimat3/core';
|
|
23
23
|
import { finiteCount, readWithinLimit, systemClock } from '@ultimat3/core';
|
|
24
|
-
import type { RateLimitStore } from '@ultimat3/http';
|
|
25
|
-
import { memoryRateLimitStore, toBucket } from '@ultimat3/http';
|
|
26
|
-
import { McpBodyTooLargeError, McpRateLimitedError } from './errors';
|
|
24
|
+
import type { Bucket, RequestFacts as McpRequestFacts, RateLimitStore } from '@ultimat3/http';
|
|
25
|
+
import { memoryRateLimitStore, requestFacts, toBucket } from '@ultimat3/http';
|
|
26
|
+
import { McpBodyTooLargeError, McpRateLimitedError } from './errors-transport';
|
|
27
27
|
import type { McpOAuth } from './oauth-metadata';
|
|
28
28
|
import {
|
|
29
29
|
assertMcpOAuth,
|
|
@@ -43,7 +43,7 @@ import { errorResponse, INVALID_REQUEST, PARSE_ERROR, refusalMessage } from './w
|
|
|
43
43
|
/**
|
|
44
44
|
* The same 1 MiB `@ultimat3/http`'s `bodyLimitBytes` defaults to. This descriptor is driven from a
|
|
45
45
|
* bare `Request` and never passes through that pipeline, so without a cap here Bun's 128 MiB
|
|
46
|
-
* default was the only ceiling — and `x mcp serve` and `
|
|
46
|
+
* default was the only ceiling — and `x mcp serve` and `httpServer` both pass no
|
|
47
47
|
* `maxRequestBodySize`.
|
|
48
48
|
*/
|
|
49
49
|
export const DEFAULT_MCP_BODY_LIMIT_BYTES = 1_048_576;
|
|
@@ -57,6 +57,18 @@ export const MCP_RATE_LIMITS: Readonly<Record<McpVerbClass, number>> = {
|
|
|
57
57
|
write: 20,
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Failed authentications per minute per ADDRESS. An address past it is refused before
|
|
62
|
+
* `resolveToken` runs, so guessing tokens is bounded and a valid guess is answered the same 429.
|
|
63
|
+
*/
|
|
64
|
+
export const MCP_UNAUTHENTICATED_LIMIT = 20;
|
|
65
|
+
|
|
66
|
+
/** `rateLimits` as declared: the two per-actor classes, and the per-address failure allowance. */
|
|
67
|
+
export type McpRateLimits = Readonly<Record<McpVerbClass, number>> & {
|
|
68
|
+
/** Failed authentications per minute per address. Defaults to `MCP_UNAUTHENTICATED_LIMIT`. */
|
|
69
|
+
readonly unauthenticated?: number | undefined;
|
|
70
|
+
};
|
|
71
|
+
|
|
60
72
|
/** What a token resolves to. `null` = unauthenticated, answered 401 with no catalog. */
|
|
61
73
|
export interface ResolvedToken {
|
|
62
74
|
readonly actor: Actor;
|
|
@@ -67,16 +79,25 @@ export interface ResolvedToken {
|
|
|
67
79
|
export interface McpHttpTransportInput {
|
|
68
80
|
readonly server: McpServer;
|
|
69
81
|
/**
|
|
70
|
-
* Resolve an OAuth bearer / personal token to a caller. The framework supplies the token
|
|
71
|
-
*
|
|
82
|
+
* Resolve an OAuth bearer / personal token to a caller. The framework supplies the token and
|
|
83
|
+
* how the request arrived (`McpRequestFacts`: the host-resolved address, user agent, `Origin`,
|
|
84
|
+
* path) — so a token can be bound to a network or refused from a browser origin. Credential
|
|
85
|
+
* storage belongs to `@ultimat3/policy` and the app, never here. A one-argument resolver is
|
|
86
|
+
* still one: the facts are an extra argument it may ignore.
|
|
72
87
|
*/
|
|
73
|
-
resolveToken(
|
|
88
|
+
resolveToken(
|
|
89
|
+
token: string,
|
|
90
|
+
facts: McpRequestFacts,
|
|
91
|
+
): Promise<ResolvedToken | null> | ResolvedToken | null;
|
|
74
92
|
/** Route path. Overridable so an app can mount a second, app-scoped surface. */
|
|
75
93
|
readonly path?: string;
|
|
76
94
|
/** Bytes this transport will hold for one request. Defaults to `DEFAULT_MCP_BODY_LIMIT_BYTES`. */
|
|
77
95
|
readonly bodyLimitBytes?: number | undefined;
|
|
78
|
-
/**
|
|
79
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Requests per minute per caller, by class — defaults to `MCP_RATE_LIMITS` — and failed
|
|
98
|
+
* authentications per minute per address (`unauthenticated`, `MCP_UNAUTHENTICATED_LIMIT`).
|
|
99
|
+
*/
|
|
100
|
+
readonly rateLimits?: McpRateLimits | undefined;
|
|
80
101
|
/**
|
|
81
102
|
* Where the buckets are counted. Defaults to a per-PROCESS memory store, which is the honest
|
|
82
103
|
* default for `x mcp serve` and a lie for N replicas behind one URL — each would enforce the
|
|
@@ -100,6 +121,12 @@ export interface McpHttpTransportInput {
|
|
|
100
121
|
export interface McpRequestOrigin {
|
|
101
122
|
/** The PUBLIC origin, `https://www.example.com`. Defaults to the request URL's own. */
|
|
102
123
|
readonly origin?: string | undefined;
|
|
124
|
+
/**
|
|
125
|
+
* The caller's address as the host resolved it (a trusted proxy's `x-forwarded-for` honoured) —
|
|
126
|
+
* what failed authentications are counted against. Absent, they are not counted here: one
|
|
127
|
+
* shared key would let a stranger's failures lock every agent out.
|
|
128
|
+
*/
|
|
129
|
+
readonly address?: string | undefined;
|
|
103
130
|
}
|
|
104
131
|
|
|
105
132
|
/** The metadata half of an `oauth` route: where to serve it and what to answer. */
|
|
@@ -116,7 +143,7 @@ export interface McpRouteDescriptor {
|
|
|
116
143
|
/** Bucket for one already-parsed body. Metering only — never an authz decision. */
|
|
117
144
|
rateLimitClass(body: unknown): McpVerbClass;
|
|
118
145
|
/** Requests per minute per caller, by class — what `handle` actually spends against. */
|
|
119
|
-
readonly limits:
|
|
146
|
+
readonly limits: McpRateLimits;
|
|
120
147
|
/** Present exactly when the route was built with `oauth`. */
|
|
121
148
|
readonly protectedResource?: McpProtectedResource;
|
|
122
149
|
handle(request: Request, seen?: McpRequestOrigin): Promise<Response>;
|
|
@@ -144,7 +171,7 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
|
|
|
144
171
|
input.bodyLimitBytes ?? DEFAULT_MCP_BODY_LIMIT_BYTES,
|
|
145
172
|
0,
|
|
146
173
|
);
|
|
147
|
-
const limits = input.rateLimits ?? MCP_RATE_LIMITS;
|
|
174
|
+
const limits: McpRateLimits = input.rateLimits ?? MCP_RATE_LIMITS;
|
|
148
175
|
const clock = input.clock ?? systemClock;
|
|
149
176
|
const store = input.rateLimitStore ?? memoryRateLimitStore();
|
|
150
177
|
// Built once, at construction: `toBucket` refuses an unusable pair (`X_RATE_LIMIT_INVALID`), and
|
|
@@ -159,6 +186,20 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
|
|
|
159
186
|
windowMs: MCP_RATE_LIMIT_WINDOW_MS,
|
|
160
187
|
});
|
|
161
188
|
|
|
189
|
+
// A COUNT of failures, screened where it is declared: `toBucket` alone would read `NaN` as a
|
|
190
|
+
// bound only at its own check, and `??` never fires for one.
|
|
191
|
+
const unauthenticated = finiteCount(
|
|
192
|
+
'mcpHttpRoute',
|
|
193
|
+
'rateLimits.unauthenticated',
|
|
194
|
+
limits.unauthenticated ?? MCP_UNAUTHENTICATED_LIMIT,
|
|
195
|
+
1,
|
|
196
|
+
);
|
|
197
|
+
const failureBucket = toBucket('mcpHttpRoute rateLimits.unauthenticated', {
|
|
198
|
+
limit: unauthenticated,
|
|
199
|
+
windowMs: MCP_RATE_LIMIT_WINDOW_MS,
|
|
200
|
+
});
|
|
201
|
+
const failures = failureMeter(store, failureBucket, clock);
|
|
202
|
+
|
|
162
203
|
const path = input.path ?? '/mcp';
|
|
163
204
|
const oauth = input.oauth;
|
|
164
205
|
if (oauth !== undefined) assertMcpOAuth(oauth);
|
|
@@ -193,13 +234,45 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
|
|
|
193
234
|
// remove, and it costs nothing to close: the body is not an input to any of these.
|
|
194
235
|
const resource = resourceOf(request, seen);
|
|
195
236
|
const metadataUrl = resource === undefined ? undefined : metadataUrlFor(resource);
|
|
237
|
+
// An address that has spent its failures is refused BEFORE the credential store is asked:
|
|
238
|
+
// counting only after the fact turned a 401 into a 429 and still answered a valid guess.
|
|
239
|
+
const address = seen?.address;
|
|
240
|
+
// Built once per request: the resolver's second argument and every refusal's audit facts.
|
|
241
|
+
// The address inside is the host's answer (`seen.address`), never a header read here.
|
|
242
|
+
const facts = requestFacts({ headers: request.headers, url: request.url, address });
|
|
243
|
+
const audit = server.audit;
|
|
244
|
+
const exhausted = await failures.refusing(address);
|
|
245
|
+
if (exhausted !== undefined) {
|
|
246
|
+
audit.authRefused('throttled', exhausted.status, facts);
|
|
247
|
+
return exhausted;
|
|
248
|
+
}
|
|
249
|
+
// A failure spends the address's allowance; the 429 wins when that one did not fit.
|
|
250
|
+
const refuse = async (
|
|
251
|
+
reason: 'missing-token' | 'rejected-token',
|
|
252
|
+
challenge: string,
|
|
253
|
+
): Promise<Response> => {
|
|
254
|
+
const throttledNow = await failures.spend(address);
|
|
255
|
+
const response = throttledNow ?? unauthorized(challenge);
|
|
256
|
+
audit.authRefused(
|
|
257
|
+
throttledNow === undefined ? reason : 'throttled',
|
|
258
|
+
response.status,
|
|
259
|
+
facts,
|
|
260
|
+
);
|
|
261
|
+
return response;
|
|
262
|
+
};
|
|
196
263
|
const token = bearerToken(request);
|
|
197
|
-
if (token === null)
|
|
264
|
+
if (token === null) {
|
|
265
|
+
return refuse('missing-token', bearerChallenge(metadataUrl, false, challengeScopes));
|
|
266
|
+
}
|
|
198
267
|
|
|
199
|
-
const resolved = await input.resolveToken(token);
|
|
200
|
-
if (resolved === null)
|
|
201
|
-
return
|
|
202
|
-
|
|
268
|
+
const resolved = await input.resolveToken(token, facts);
|
|
269
|
+
if (resolved === null) {
|
|
270
|
+
return refuse('rejected-token', bearerChallenge(metadataUrl, true, challengeScopes));
|
|
271
|
+
}
|
|
272
|
+
if (!isAgentActor(resolved.actor)) {
|
|
273
|
+
audit.authRefused('not-an-agent', 403, facts);
|
|
274
|
+
return notAnAgent();
|
|
275
|
+
}
|
|
203
276
|
|
|
204
277
|
// Read through the counting reader, never `request.json()`: the cap has to be enforced
|
|
205
278
|
// WHILE the bytes arrive, or a `transfer-encoding: chunked` payload is materialised in full
|
|
@@ -275,6 +348,31 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
|
|
|
275
348
|
};
|
|
276
349
|
}
|
|
277
350
|
|
|
351
|
+
/**
|
|
352
|
+
* The per-address failure allowance. `refusing` reads the bucket (`RateLimitStore.peek`, which
|
|
353
|
+
* writes nothing) and answers the 429 when not one more failure fits; `spend` charges one
|
|
354
|
+
* failure and answers the 429 when that one did not fit. `undefined` from either: carry on.
|
|
355
|
+
*/
|
|
356
|
+
function failureMeter(store: RateLimitStore, bucket: Bucket, clock: Clock) {
|
|
357
|
+
const keyOf = (address: string): string => `mcp:unauthenticated|ip:${address}`;
|
|
358
|
+
const refusal = (retryAfterSeconds: number): Response =>
|
|
359
|
+
throttled('unauthenticated', bucket.capacity, retryAfterSeconds);
|
|
360
|
+
return {
|
|
361
|
+
async refusing(address: string | undefined): Promise<Response | undefined> {
|
|
362
|
+
if (address === undefined) return undefined;
|
|
363
|
+
// READ ONLY: this runs on every request, an agent's included, and a write per call would be
|
|
364
|
+
// a Postgres upsert on the hot path of every authenticated one.
|
|
365
|
+
const peek = await store.peek(keyOf(address), bucket, clock.now().getTime());
|
|
366
|
+
return peek.remaining >= 1 ? undefined : refusal(peek.retryAfterSeconds);
|
|
367
|
+
},
|
|
368
|
+
async spend(address: string | undefined): Promise<Response | undefined> {
|
|
369
|
+
if (address === undefined) return undefined;
|
|
370
|
+
const spent = await store.take(keyOf(address), bucket, 1, clock.now().getTime());
|
|
371
|
+
return spent.allowed ? undefined : refusal(spent.retryAfterSeconds);
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
278
376
|
/** `Authorization: Bearer <token>`, the only accepted form. No query-string tokens. */
|
|
279
377
|
export function bearerToken(request: Request): string | null {
|
|
280
378
|
const header = request.headers.get('authorization');
|
|
@@ -298,7 +396,11 @@ export function isAgentActor(actor: Actor): boolean {
|
|
|
298
396
|
* call to answer. `retry-after` carries the same number the `fix:` line names, because an agent
|
|
299
397
|
* reads one of the two and must not get different answers from them.
|
|
300
398
|
*/
|
|
301
|
-
function throttled(
|
|
399
|
+
function throttled(
|
|
400
|
+
verbClass: keyof McpRateLimits,
|
|
401
|
+
limit: number,
|
|
402
|
+
retryAfterSeconds: number,
|
|
403
|
+
): Response {
|
|
302
404
|
const error = new McpRateLimitedError({ verbClass, limit, retryAfterSeconds });
|
|
303
405
|
return new Response(JSON.stringify({ code: error.code, cause: error.cause, fix: error.fix }), {
|
|
304
406
|
status: 429,
|
package/src/transport-stdio.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// stderr and this file never calls `console.log`.
|
|
10
10
|
|
|
11
11
|
import { finiteCount } from '@ultimat3/core';
|
|
12
|
-
import { McpBodyTooLargeError } from './errors';
|
|
12
|
+
import { McpBodyTooLargeError } from './errors-transport';
|
|
13
13
|
import type { McpCaller } from './registry';
|
|
14
14
|
import type { McpServer } from './server';
|
|
15
15
|
import { errorResponse, INVALID_REQUEST, PARSE_ERROR, refusalMessage } from './wire';
|
package/src/type-pins.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Compile-time pins for this package's public types. Source, not a `.test.ts`, on purpose:
|
|
2
|
+
// `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b` never reads a test file and a
|
|
3
|
+
// type-level claim written in one can never fail. This module emits nothing and exports nothing
|
|
4
|
+
// anybody imports — a regression here is a build error, the only enforcement that counts.
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
McpListParams as CoreListParams,
|
|
8
|
+
McpAnnotationHints,
|
|
9
|
+
McpExposureDeclaration,
|
|
10
|
+
} from '@ultimat3/core';
|
|
11
|
+
import type { ProjectablePrimitive } from './from-action';
|
|
12
|
+
import type { McpListParams } from './list-params';
|
|
13
|
+
import type { McpToolAnnotations } from './registry';
|
|
14
|
+
|
|
15
|
+
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
16
|
+
type Assert<T extends true> = T;
|
|
17
|
+
|
|
18
|
+
type Equals<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* IDENTICAL, not merely mutually assignable: an object type without an optional key is assignable
|
|
22
|
+
* to one with it and back, so `Equals` cannot see an optional field added on one side only — and
|
|
23
|
+
* every field of the `mcp` block is optional but `expose`.
|
|
24
|
+
*/
|
|
25
|
+
type Identical<A, B> =
|
|
26
|
+
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
|
|
27
|
+
|
|
28
|
+
type Projected = NonNullable<ProjectablePrimitive['mcp']>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 25.0.0: the projection reads core's ONE `mcp` block. `McpExposure` restated it here, a field
|
|
32
|
+
* looser (`expose?`) than the block an action or query is held to; a twin coming back as a
|
|
33
|
+
* different shape flips this to `false`.
|
|
34
|
+
*/
|
|
35
|
+
export type _ProjectionReadsCoresBlock = Assert<Identical<Projected, McpExposureDeclaration>>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 25.0.0 (plan 101, M4): a projected tool is named by its primitive's `name` and by nothing
|
|
39
|
+
* else. `McpExposure.name` was a second naming field no declaration could set; one coming back
|
|
40
|
+
* on core's block flips this to `false`.
|
|
41
|
+
*/
|
|
42
|
+
export type _McpBlockCarriesNoName = Assert<Equals<Extract<keyof Projected, 'name'>, never>>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The tool's own wire types (`McpTool.annotations`, `McpTool.listParams`) are what the block's
|
|
46
|
+
* fields become, so they must stay the block's shapes exactly: a hint or a whitelist key added
|
|
47
|
+
* on one side only is a declaration the other silently drops.
|
|
48
|
+
*/
|
|
49
|
+
export type _ToolAnnotationsAreTheBlocks = Assert<
|
|
50
|
+
Identical<McpToolAnnotations, McpAnnotationHints>
|
|
51
|
+
>;
|
|
52
|
+
export type _ToolListParamsAreTheBlocks = Assert<Identical<McpListParams, CoreListParams>>;
|
package/src/validate-args.ts
CHANGED
|
@@ -174,7 +174,8 @@ export const compiledPatternCount = (): number => compilations;
|
|
|
174
174
|
* A pattern this server cannot COMPILE is refused, not skipped. `tools/list` published it, so an
|
|
175
175
|
* agent has already been told the rule — passing a call the server cannot check is the silent-pass
|
|
176
176
|
* this whole module exists to prevent. Every framework-projected pattern is a `RegExp.source` and
|
|
177
|
-
* compiles; only a hand-written tool can reach the second branch, and that is its
|
|
177
|
+
* its own flags and compiles; only a hand-written tool can reach the second branch, and that is its
|
|
178
|
+
* author's bug.
|
|
178
179
|
*/
|
|
179
180
|
function matchesPattern(
|
|
180
181
|
schema: JsonSchema,
|
|
@@ -183,21 +184,28 @@ function matchesPattern(
|
|
|
183
184
|
path: string,
|
|
184
185
|
issues: ArgIssue[],
|
|
185
186
|
): void {
|
|
187
|
+
// The flags `@ultimat3/schema` published beside the source. Without them `/^[a-z]+$/i` refused
|
|
188
|
+
// `ABC` and `/^\p{L}+$/u` refused `é` here, while the action's own parse accepted both.
|
|
189
|
+
const flags = schema['x-ultimate-pattern-flags'] ?? '';
|
|
190
|
+
const shown = flags === '' ? pattern : `/${pattern}/${flags}`;
|
|
186
191
|
let compiled = compiledPatterns.get(schema);
|
|
187
192
|
if (compiled === undefined) {
|
|
188
193
|
compilations += 1;
|
|
189
194
|
try {
|
|
190
|
-
compiled = new RegExp(pattern);
|
|
195
|
+
compiled = new RegExp(pattern, flags);
|
|
191
196
|
} catch {
|
|
192
197
|
compiled = null;
|
|
193
198
|
}
|
|
194
199
|
compiledPatterns.set(schema, compiled);
|
|
195
200
|
}
|
|
196
201
|
if (compiled === null) {
|
|
197
|
-
issues.push({ path, message: `declares a pattern this server cannot compile: ${
|
|
202
|
+
issues.push({ path, message: `declares a pattern this server cannot compile: ${shown}` });
|
|
198
203
|
return;
|
|
199
204
|
}
|
|
200
|
-
|
|
205
|
+
// A `g` or `y` RegExp carries `lastIndex` between calls, and this one is cached per node: the
|
|
206
|
+
// second `.test()` of an accepted value would answer false. Reset, as the schema's tester does.
|
|
207
|
+
compiled.lastIndex = 0;
|
|
208
|
+
if (!compiled.test(input)) issues.push({ path, message: `must match ${shown}` });
|
|
201
209
|
}
|
|
202
210
|
|
|
203
211
|
function number(schema: JsonSchema, input: unknown, path: string, issues: ArgIssue[]): unknown {
|