@ultimat3/mcp 19.3.1 → 19.3.2
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 +5 -0
- package/README.md +8 -0
- package/package.json +9 -9
- package/src/app-tools.ts +8 -0
- package/src/errors.ts +58 -1
- package/src/index.ts +2 -1
- package/src/server.ts +64 -5
- package/src/transport-http.ts +24 -9
- package/src/transport-stdio.ts +18 -12
- package/src/wire.ts +10 -0
package/CLAUDE.md
CHANGED
|
@@ -39,6 +39,11 @@ import. The CLI wires it.
|
|
|
39
39
|
- `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim**, so a `defineAppMcp` file
|
|
40
40
|
imports one package. Never wrap, spread or re-declare it: `t` delegates to `schemaProvider()` on
|
|
41
41
|
every access, and a copy would freeze the provider at import time. `index.test.ts` asserts identity.
|
|
42
|
+
- **Every refusal carries its instruction** (`As of 2026-09-07`). The 413 and the batch refusal
|
|
43
|
+
were bare `-32600`s; both now carry `data: { code, fix }` built by the error class that owns the
|
|
44
|
+
wording (`McpBodyTooLargeError`, shared with the stdio line cap; `McpProtocolError`). The
|
|
45
|
+
`-32601` for a tool carries no `data` — see the next rule — so its instruction rides in the
|
|
46
|
+
MESSAGE, and it is `TOOL_UNKNOWN_FIX`, the same sentence on the absent and the hidden branch.
|
|
42
47
|
- Three outcomes, never blurred: role-hidden → `-32601` ToolNotFound with no `data`;
|
|
43
48
|
scope → `-32600` `X_MCP_SCOPE_DENIED` naming the scope; policy → an `isError` result
|
|
44
49
|
carrying `X_FORBIDDEN`. Swapping any two is an enumeration oracle.
|
package/README.md
CHANGED
|
@@ -198,8 +198,16 @@ It cannot be done from outside: `rateLimitClass(body)` takes an already-parsed b
|
|
|
198
198
|
the only thing that parses one. The bucket is `@ultimat3/http`'s, keyed per actor per class; over
|
|
199
199
|
the limit is `429` + `Retry-After` + `X_MCP_RATE_LIMITED`.
|
|
200
200
|
|
|
201
|
+
The body is capped WHILE it is read, and over the cap is `413` on a JSON-RPC `-32600` (`id: null`)
|
|
202
|
+
carrying `data: { code: 'X_MCP_BODY_TOO_LARGE', cause, fix, limit }` — the stdio transport answers
|
|
203
|
+
an over-long line with the same code. A JSON-RPC batch (an array) is refused `-32600` by name with
|
|
204
|
+
`data: { code: 'X_MCP_PROTOCOL', fix }`: one request per `POST`, never walked. Every refusal on
|
|
205
|
+
this surface carries its instruction, `As of 2026-09-07` — the 413 and the batch were the two that
|
|
206
|
+
did not.
|
|
207
|
+
|
|
201
208
|
| Knob | Where | Default |
|
|
202
209
|
|---|---|---|
|
|
210
|
+
| the body cap | `mcpHttpRoute({ bodyLimitBytes })` · `defineAppMcp({ bodyLimitBytes })` | `DEFAULT_MCP_BODY_LIMIT_BYTES`, 1 MiB |
|
|
203
211
|
| the numbers | `mcpHttpRoute({ rateLimits })` · `defineAppMcp({ rateLimits })` | `MCP_RATE_LIMITS` |
|
|
204
212
|
| where they are counted | `mcpHttpRoute({ rateLimitStore })` · `defineAppMcp({ rateLimitStore })` | a per-**process** memory store — N replicas behind one URL each enforce the full allowance, so a fleet passes `postgresRateLimitStore({ executor })` |
|
|
205
213
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/mcp",
|
|
3
|
-
"version": "19.3.
|
|
3
|
+
"version": "19.3.2",
|
|
4
4
|
"description": "MCP server, dev tools, and the action-to-tool projection \u2014 one authz system, two surfaces",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,13 +31,13 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/action": "19.3.
|
|
35
|
-
"@ultimat3/core": "19.3.
|
|
36
|
-
"@ultimat3/entity": "19.3.
|
|
37
|
-
"@ultimat3/http": "19.3.
|
|
38
|
-
"@ultimat3/jobs": "19.3.
|
|
39
|
-
"@ultimat3/policy": "19.3.
|
|
40
|
-
"@ultimat3/query": "19.3.
|
|
41
|
-
"@ultimat3/schema": "19.3.
|
|
34
|
+
"@ultimat3/action": "19.3.2",
|
|
35
|
+
"@ultimat3/core": "19.3.2",
|
|
36
|
+
"@ultimat3/entity": "19.3.2",
|
|
37
|
+
"@ultimat3/http": "19.3.2",
|
|
38
|
+
"@ultimat3/jobs": "19.3.2",
|
|
39
|
+
"@ultimat3/policy": "19.3.2",
|
|
40
|
+
"@ultimat3/query": "19.3.2",
|
|
41
|
+
"@ultimat3/schema": "19.3.2"
|
|
42
42
|
}
|
|
43
43
|
}
|
package/src/app-tools.ts
CHANGED
|
@@ -78,6 +78,13 @@ export interface DefineAppMcpInput<TSchemas extends AppToolSchemas = AppToolSche
|
|
|
78
78
|
resolveToken?(token: string): Promise<ResolvedToken | null> | ResolvedToken | null;
|
|
79
79
|
/** Mount path. Defaults to `/mcp`. */
|
|
80
80
|
readonly path?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Bytes the route holds for one request. Defaults to `DEFAULT_MCP_BODY_LIMIT_BYTES` (1 MiB).
|
|
83
|
+
* Forwarded since 2026-09-07: `mcpHttpRoute` took it and this — the one path an app builds the
|
|
84
|
+
* route through — never passed it on, so `X_MCP_BODY_TOO_LARGE`'s fix line named a knob the app
|
|
85
|
+
* could not reach.
|
|
86
|
+
*/
|
|
87
|
+
readonly bodyLimitBytes?: number | undefined;
|
|
81
88
|
/**
|
|
82
89
|
* Requests per minute per caller, by class. Defaults to `MCP_RATE_LIMITS` and is ENFORCED by the
|
|
83
90
|
* route, so this is the app's one knob over what an agent may spend here.
|
|
@@ -168,6 +175,7 @@ export function defineAppMcp<TSchemas extends AppToolSchemas>(
|
|
|
168
175
|
server,
|
|
169
176
|
resolveToken,
|
|
170
177
|
...(input.path !== undefined ? { path: input.path } : {}),
|
|
178
|
+
...(input.bodyLimitBytes !== undefined ? { bodyLimitBytes: input.bodyLimitBytes } : {}),
|
|
171
179
|
...(input.rateLimits !== undefined ? { rateLimits: input.rateLimits } : {}),
|
|
172
180
|
...(input.rateLimitStore !== undefined ? { rateLimitStore: input.rateLimitStore } : {}),
|
|
173
181
|
});
|
package/src/errors.ts
CHANGED
|
@@ -18,6 +18,7 @@ export const MCP_ERROR_CODES = [
|
|
|
18
18
|
'X_MCP_SCOPE_CONFLICT',
|
|
19
19
|
'X_MCP_RATE_LIMITED',
|
|
20
20
|
'X_MCP_APP_UNMOUNTED',
|
|
21
|
+
'X_MCP_BODY_TOO_LARGE',
|
|
21
22
|
] as const;
|
|
22
23
|
|
|
23
24
|
export type McpErrorCode = (typeof MCP_ERROR_CODES)[number];
|
|
@@ -37,6 +38,7 @@ export const MCP_ERROR_TITLES: Readonly<Record<McpErrorCode, string>> = {
|
|
|
37
38
|
X_MCP_SCOPE_CONFLICT: 'two scopes claim one MCP tool',
|
|
38
39
|
X_MCP_RATE_LIMITED: "the caller has spent its allowance for this request's class",
|
|
39
40
|
X_MCP_APP_UNMOUNTED: "the app's MCP endpoint is exposed in config and nothing can be mounted",
|
|
41
|
+
X_MCP_BODY_TOO_LARGE: 'one MCP message is larger than the transport holds',
|
|
40
42
|
};
|
|
41
43
|
|
|
42
44
|
// Titles must be registered for `format()` to render the contract's first line. Every code above is
|
|
@@ -53,6 +55,15 @@ registerErrorCodes(
|
|
|
53
55
|
// answered 404, host included, on every error it has ever thrown; restating the replacement here
|
|
54
56
|
// would be the same constant in eight places waiting to drift again.
|
|
55
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The one instruction that is SAFE on both of outcome 1's branches. `server.ts` appends it to the
|
|
60
|
+
* wire message for a hidden tool and for an absent one alike, and `McpToolUnknownError` is its
|
|
61
|
+
* in-process twin — one constant, so the sentence an agent reads over `-32601` and the one it
|
|
62
|
+
* reads from a thrown error cannot drift. It names `tools/list` and nothing about the name it was
|
|
63
|
+
* given: whether that name exists is exactly what the answer must not say.
|
|
64
|
+
*/
|
|
65
|
+
export const TOOL_UNKNOWN_FIX = 'call tools/list to read the catalog this caller may use';
|
|
66
|
+
|
|
56
67
|
/**
|
|
57
68
|
* OUTCOME 1 of three: a tool name reached the dispatcher that no VISIBLE tool answers to —
|
|
58
69
|
* the tool is absent, or it exists and this caller's role may never invoke it. One error for
|
|
@@ -67,7 +78,7 @@ export class McpToolUnknownError extends UltimateError {
|
|
|
67
78
|
cause: `no MCP tool named "${input.name}" is visible to this caller (visible: ${
|
|
68
79
|
input.visible.length > 0 ? input.visible.join(', ') : 'none'
|
|
69
80
|
})`,
|
|
70
|
-
fix:
|
|
81
|
+
fix: TOOL_UNKNOWN_FIX,
|
|
71
82
|
});
|
|
72
83
|
}
|
|
73
84
|
}
|
|
@@ -324,6 +335,52 @@ export class McpNotBranchDbError extends UltimateError {
|
|
|
324
335
|
}
|
|
325
336
|
}
|
|
326
337
|
|
|
338
|
+
/**
|
|
339
|
+
* One message larger than the transport holds. Its own code, for the reason `X_MCP_RATE_LIMITED`
|
|
340
|
+
* below is not `X_RATE_LIMITED`: `@ultimat3/http`'s `X_BODY_INVALID` names `bodyLimitBytes` on the
|
|
341
|
+
* HTTP pipeline, and this route never passes through it — the knob is `mcpHttpRoute`'s (or
|
|
342
|
+
* `defineAppMcp`'s, which forwards it), and over stdio it is `serveStdio({ lineLimitBytes })`, a
|
|
343
|
+
* ceiling in characters rather than bytes. One class for both transports, because the condition is
|
|
344
|
+
* one: the peer sent more in one message than this end agreed to hold, and the two answers must
|
|
345
|
+
* name the same code so an agent learns it once.
|
|
346
|
+
*
|
|
347
|
+
* Measured through ai-maxxing's `POST /mcp` on 2026-09-07: the 413 was a bare `-32600` reading
|
|
348
|
+
* `request body is at least N bytes, limit is M` — two numbers and no next step, while the 401,
|
|
349
|
+
* 403 and 429 beside it all carried `{ code, cause, fix }`. A box agent sending a large
|
|
350
|
+
* `promptSession` had nothing to act on. The fix names both moves, send less or raise the cap,
|
|
351
|
+
* because which one is right is the author's call: a prompt can be split, a paged result cannot
|
|
352
|
+
* always be, and a cap set by default was never a decision anyone made about this app.
|
|
353
|
+
*/
|
|
354
|
+
export class McpBodyTooLargeError extends UltimateError {
|
|
355
|
+
readonly limit: number;
|
|
356
|
+
|
|
357
|
+
constructor(input: { transport: 'http' | 'stdio'; limit: number; over?: number | undefined }) {
|
|
358
|
+
const http = input.transport === 'http';
|
|
359
|
+
// A NUMBER in the fix, never a `<n>`: the contract is a one-line edit that runs as written. The
|
|
360
|
+
// cap itself would run and change nothing; twice the larger of the cap and what arrived is a
|
|
361
|
+
// value that admits this message, whichever of the two was the shorter measure. Both are
|
|
362
|
+
// finite non-negative integers by the transports' own screens, so the product is too.
|
|
363
|
+
const raised = 2 * Math.max(input.limit, input.over ?? 0);
|
|
364
|
+
super({
|
|
365
|
+
code: 'X_MCP_BODY_TOO_LARGE',
|
|
366
|
+
cause: http
|
|
367
|
+
? `request body is at least ${input.over ?? input.limit} bytes, limit is ${input.limit}`
|
|
368
|
+
: `one message exceeded ${input.limit} characters and was dropped`,
|
|
369
|
+
fix: http
|
|
370
|
+
? `send less in one request — page a large read, split a long prompt across calls — or raise the cap where the route is built: mcpHttpRoute({ bodyLimitBytes: ${raised} }) or defineAppMcp({ bodyLimitBytes: ${raised} })`
|
|
371
|
+
: `send one JSON-RPC message per line, each under the cap — split a large tool result into paged calls — or raise it where the transport is started: serveStdio({ lineLimitBytes: ${raised} })`,
|
|
372
|
+
// The numbers as FIELDS, not only prose: a `--json` reader and the wire's `data` both want
|
|
373
|
+
// the limit without re-parsing a sentence.
|
|
374
|
+
meta: {
|
|
375
|
+
transport: input.transport,
|
|
376
|
+
limit: input.limit,
|
|
377
|
+
...(input.over === undefined ? {} : { over: input.over }),
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
this.limit = input.limit;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
327
384
|
/**
|
|
328
385
|
* The transport's own throttle, and its own CODE rather than `@ultimat3/http`'s `X_RATE_LIMITED`
|
|
329
386
|
* — not because the maths differ (they are the same `Bucket`, the same store and the same
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ export {
|
|
|
35
35
|
MCP_ERROR_TITLES,
|
|
36
36
|
McpAppUnmountedError,
|
|
37
37
|
McpArgsInvalidError,
|
|
38
|
+
McpBodyTooLargeError,
|
|
38
39
|
McpNotBranchDbError,
|
|
39
40
|
McpProtocolError,
|
|
40
41
|
McpQueryRejectedError,
|
|
@@ -102,7 +103,7 @@ export {
|
|
|
102
103
|
} from './resources';
|
|
103
104
|
export type { McpScopes } from './scopes';
|
|
104
105
|
export { withScopes } from './scopes';
|
|
105
|
-
export type { CreateMcpServerInput } from './server';
|
|
106
|
+
export type { CreateMcpServerInput, McpWire } from './server';
|
|
106
107
|
export { createMcpServer, McpServer } from './server';
|
|
107
108
|
export type {
|
|
108
109
|
McpHttpTransportInput,
|
package/src/server.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { FRAMEWORK_CODE, singleLine, stringField } from '@ultimat3/core';
|
|
7
7
|
import { formatIssues } from '@ultimat3/schema';
|
|
8
8
|
import { auditResourceRead, auditToolCall, outcomeForCode, outcomeForResult } from './audit';
|
|
9
|
-
import { McpScopeDeniedError } from './errors';
|
|
9
|
+
import { McpProtocolError, McpScopeDeniedError, TOOL_UNKNOWN_FIX } from './errors';
|
|
10
10
|
import type { AnyMcpTool, McpCaller, McpToolResult, McpVerbClass, ToolListEntry } from './registry';
|
|
11
11
|
import { ToolRegistry } from './registry';
|
|
12
12
|
import type { McpPrompt, McpResource } from './resources';
|
|
@@ -26,6 +26,23 @@ import {
|
|
|
26
26
|
resultResponse,
|
|
27
27
|
} from './wire';
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Which wire one message arrived on, so a refusal can name the unit THAT transport counts in.
|
|
31
|
+
* `handle` is transport-independent and stays so — this is a hint for the wording of one fix, never
|
|
32
|
+
* a branch in dispatch. HTTP carries its mounted path because `mcpHttpRoute({ path })` is a knob:
|
|
33
|
+
* a batch refusal that told every client to retry `POST /mcp` was wrong for an app mounted at
|
|
34
|
+
* `/app-mcp`, which is how this came to be threaded rather than spelled.
|
|
35
|
+
*/
|
|
36
|
+
export type McpWire =
|
|
37
|
+
| { readonly transport: 'http'; readonly path: string }
|
|
38
|
+
| { readonly transport: 'stdio' };
|
|
39
|
+
|
|
40
|
+
/** The unit a transport counts one request in — or the neutral word when no wire said. */
|
|
41
|
+
const messageUnitOf = (wire: McpWire | undefined): string => {
|
|
42
|
+
if (wire === undefined) return 'message';
|
|
43
|
+
return wire.transport === 'http' ? `POST ${wire.path}` : 'line';
|
|
44
|
+
};
|
|
45
|
+
|
|
29
46
|
export interface CreateMcpServerInput {
|
|
30
47
|
readonly tools?: readonly AnyMcpTool[];
|
|
31
48
|
readonly resources?: readonly McpResource[];
|
|
@@ -72,9 +89,34 @@ export class McpServer {
|
|
|
72
89
|
this.serverInfo = serverInfo;
|
|
73
90
|
}
|
|
74
91
|
|
|
75
|
-
async handle(
|
|
92
|
+
async handle(
|
|
93
|
+
body: unknown,
|
|
94
|
+
caller: McpCaller,
|
|
95
|
+
wire?: McpWire | undefined,
|
|
96
|
+
): Promise<JsonRpcResponse | null> {
|
|
97
|
+
// A batch is legal JSON-RPC 2.0 and this server does not walk one: one call, one answer, one
|
|
98
|
+
// rate-limit class. Refused BY NAME rather than falling through to the envelope check below —
|
|
99
|
+
// an array is not an envelope, so it did, and a client sending a batch got the same bare
|
|
100
|
+
// `-32600` as `{ not: 'jsonrpc' }` with no word that batching was the problem. Measured
|
|
101
|
+
// through ai-maxxing's `POST /mcp` on 2026-09-07. The `message` is the same on every wire;
|
|
102
|
+
// only the fix names the unit — `POST <path>` as mounted, or a line — and only when the
|
|
103
|
+
// transport said which it is.
|
|
104
|
+
if (Array.isArray(body)) {
|
|
105
|
+
return protocolRefusal(
|
|
106
|
+
'a JSON-RPC batch (an array of requests) is not supported: send one request per message',
|
|
107
|
+
new McpProtocolError({
|
|
108
|
+
cause: 'the body is a JSON-RPC batch, and this server answers one request per message',
|
|
109
|
+
fix: `send one request per ${messageUnitOf(wire)} — a batch is never walked, so its calls did not run`,
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
76
113
|
if (!isJsonRpcRequest(body)) {
|
|
77
|
-
return
|
|
114
|
+
return protocolRefusal(
|
|
115
|
+
'not a JSON-RPC 2.0 request envelope',
|
|
116
|
+
new McpProtocolError({
|
|
117
|
+
cause: 'the body is not a JSON-RPC 2.0 request envelope',
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
78
120
|
}
|
|
79
121
|
// Notifications get no answer at all; the transport replies 202 with an empty body.
|
|
80
122
|
if (isNotification(body)) return null;
|
|
@@ -145,10 +187,13 @@ export class McpServer {
|
|
|
145
187
|
const resolved = this.tools.resolve(name, params['arguments'] ?? {}, caller);
|
|
146
188
|
switch (resolved.kind) {
|
|
147
189
|
// OUTCOME 1. Absent AND role-hidden collapse to the same answer, with no `data` at
|
|
148
|
-
// all: any extra field would be the difference a prober is looking for.
|
|
190
|
+
// all: any extra field would be the difference a prober is looking for. The message
|
|
191
|
+
// carries the one instruction that holds on both branches — read `tools/list` — and
|
|
192
|
+
// nothing about whether the name exists: the same sentence for a stale name and for a
|
|
193
|
+
// tool this role may never see, so the hint is not a second oracle.
|
|
149
194
|
case 'not-found':
|
|
150
195
|
auditToolCall({ tool: name, outcome: 'hidden', caller, code: 'X_MCP_TOOL_UNKNOWN' });
|
|
151
|
-
return errorResponse(id, METHOD_NOT_FOUND, `tool not found: ${name}`);
|
|
196
|
+
return errorResponse(id, METHOD_NOT_FOUND, `tool not found: ${name} — ${TOOL_UNKNOWN_FIX}`);
|
|
152
197
|
// OUTCOME 2. The caller can already see this tool, so naming the missing scope leaks
|
|
153
198
|
// nothing — and the fix travels with it, built by the error that owns the wording.
|
|
154
199
|
case 'scope-denied': {
|
|
@@ -302,6 +347,20 @@ export class McpServer {
|
|
|
302
347
|
}
|
|
303
348
|
}
|
|
304
349
|
|
|
350
|
+
/**
|
|
351
|
+
* The envelope refusals — no id to answer on, so `null` — rendered the way the scope refusal
|
|
352
|
+
* already is: a `message` a client library surfaces verbatim, and `data: { code, fix, docs }`
|
|
353
|
+
* built by the error class that owns the wording. Every other refusal on this surface carried its
|
|
354
|
+
* instruction; these two answered a bare `-32600` and left the caller to guess.
|
|
355
|
+
*/
|
|
356
|
+
function protocolRefusal(message: string, error: McpProtocolError): JsonRpcResponse {
|
|
357
|
+
return errorResponse(null, INVALID_REQUEST, message, {
|
|
358
|
+
code: error.code,
|
|
359
|
+
fix: error.fix,
|
|
360
|
+
docs: error.docs,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
305
364
|
interface FrameworkError {
|
|
306
365
|
readonly code: string;
|
|
307
366
|
/** `''` for a foreign thrown object that carries no title. See `renderFrameworkError`. */
|
package/src/transport-http.ts
CHANGED
|
@@ -23,11 +23,11 @@ import type { Actor, Clock } from '@ultimat3/core';
|
|
|
23
23
|
import { finiteCount, readWithinLimit, systemClock } from '@ultimat3/core';
|
|
24
24
|
import type { RateLimitStore } from '@ultimat3/http';
|
|
25
25
|
import { memoryRateLimitStore, toBucket } from '@ultimat3/http';
|
|
26
|
-
import { McpRateLimitedError } from './errors';
|
|
26
|
+
import { McpBodyTooLargeError, McpRateLimitedError } from './errors';
|
|
27
27
|
import type { McpCaller, McpRole, McpVerbClass } from './registry';
|
|
28
28
|
import type { McpServer } from './server';
|
|
29
29
|
import type { JsonRpcResponse } from './wire';
|
|
30
|
-
import { errorResponse, INVALID_REQUEST, PARSE_ERROR } from './wire';
|
|
30
|
+
import { errorResponse, INVALID_REQUEST, PARSE_ERROR, refusalMessage } from './wire';
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* The same 1 MiB `@ultimat3/http`'s `bodyLimitBytes` defaults to. This descriptor is driven from a
|
|
@@ -117,9 +117,11 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
|
|
|
117
117
|
windowMs: MCP_RATE_LIMIT_WINDOW_MS,
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
+
const path = input.path ?? '/mcp';
|
|
121
|
+
|
|
120
122
|
return {
|
|
121
123
|
method: 'POST',
|
|
122
|
-
path
|
|
124
|
+
path,
|
|
123
125
|
limits,
|
|
124
126
|
rateLimitClass: (body) => server.classify(body),
|
|
125
127
|
|
|
@@ -141,12 +143,23 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
|
|
|
141
143
|
// cannot drift.
|
|
142
144
|
const read = await readWithinLimit(request.body, bodyLimitBytes);
|
|
143
145
|
if ('over' in read) {
|
|
146
|
+
// Still the JSON-RPC envelope on `id: null` — a client library parses that and a consumer
|
|
147
|
+
// has pinned it — and the stdio transport answers the same condition the same way. What
|
|
148
|
+
// travels now is the refusal: the code, both numbers, and the two moves that end it,
|
|
149
|
+
// built by the error that owns the wording so the two transports cannot drift.
|
|
150
|
+
const refusal = new McpBodyTooLargeError({
|
|
151
|
+
transport: 'http',
|
|
152
|
+
limit: bodyLimitBytes,
|
|
153
|
+
over: read.over,
|
|
154
|
+
});
|
|
144
155
|
return json(
|
|
145
|
-
errorResponse(
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
156
|
+
errorResponse(null, INVALID_REQUEST, refusalMessage(refusal), {
|
|
157
|
+
code: refusal.code,
|
|
158
|
+
cause: refusal.cause,
|
|
159
|
+
fix: refusal.fix,
|
|
160
|
+
docs: refusal.docs,
|
|
161
|
+
limit: bodyLimitBytes,
|
|
162
|
+
}),
|
|
150
163
|
413,
|
|
151
164
|
);
|
|
152
165
|
}
|
|
@@ -185,7 +198,9 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
|
|
|
185
198
|
...(resolved.role !== undefined ? { role: resolved.role } : {}),
|
|
186
199
|
};
|
|
187
200
|
|
|
188
|
-
|
|
201
|
+
// The wire named, so a refusal that tells the client where to resend names THIS mount and
|
|
202
|
+
// not a spelled `/mcp`.
|
|
203
|
+
const response = await server.handle(body, caller, { transport: 'http', path });
|
|
189
204
|
// A notification has no response. 202 with an empty body is the MCP-correct answer.
|
|
190
205
|
if (response === null) return new Response(null, { status: 202 });
|
|
191
206
|
// JSON-RPC errors are 200s: the transport succeeded, the call did not. Only a
|
package/src/transport-stdio.ts
CHANGED
|
@@ -9,9 +9,10 @@
|
|
|
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
13
|
import type { McpCaller } from './registry';
|
|
13
14
|
import type { McpServer } from './server';
|
|
14
|
-
import { errorResponse, INVALID_REQUEST, PARSE_ERROR } from './wire';
|
|
15
|
+
import { errorResponse, INVALID_REQUEST, PARSE_ERROR, refusalMessage } from './wire';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Characters held for ONE message that has not ended yet. `transport-http.ts` caps the same wire at
|
|
@@ -92,17 +93,22 @@ export async function serveStdio(config: StdioTransportInput): Promise<void> {
|
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
|
|
95
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* Answered once per over-long message, and the `fix` is what the peer has to change. The same
|
|
98
|
+
* `X_MCP_BODY_TOO_LARGE` the HTTP transport's 413 carries: one condition, one code, on both wires.
|
|
99
|
+
*/
|
|
96
100
|
function overLimit(limit: number): ReturnType<typeof errorResponse> {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
const refusal = new McpBodyTooLargeError({ transport: 'stdio', limit });
|
|
102
|
+
// `message` carries the fix as the HTTP 413's does: a peer reading nothing but `message` still
|
|
103
|
+
// learns the next move. The two transports' words differ — a line is not a body — and the SHAPE
|
|
104
|
+
// is the one `refusalMessage` owns.
|
|
105
|
+
return errorResponse(null, INVALID_REQUEST, refusalMessage(refusal), {
|
|
106
|
+
code: refusal.code,
|
|
107
|
+
cause: refusal.cause,
|
|
108
|
+
fix: refusal.fix,
|
|
109
|
+
docs: refusal.docs,
|
|
110
|
+
limit,
|
|
111
|
+
});
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
async function handleLine(
|
|
@@ -122,7 +128,7 @@ async function handleLine(
|
|
|
122
128
|
return;
|
|
123
129
|
}
|
|
124
130
|
|
|
125
|
-
const response = await server.handle(body, caller);
|
|
131
|
+
const response = await server.handle(body, caller, { transport: 'stdio' });
|
|
126
132
|
// `null` means notification: emit nothing at all, or the peer sees a phantom reply.
|
|
127
133
|
if (response === null) return;
|
|
128
134
|
await write(`${JSON.stringify(response)}\n`);
|
package/src/wire.ts
CHANGED
|
@@ -111,6 +111,16 @@ export function resultResponse(id: JsonRpcId, result: unknown): JsonRpcResponse
|
|
|
111
111
|
return { jsonrpc: '2.0', id, result };
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The `message` a coded refusal travels under: the cause AND the fix, in one sentence. `data`
|
|
116
|
+
* carries both as fields; `message` is the ONE thing a client that reads nothing else prints, so a
|
|
117
|
+
* consumer of `message` alone still gets the recovery step. Both transports build theirs here —
|
|
118
|
+
* the HTTP 413 and the stdio over-long frame carry transport-specific wording, and this keeps the
|
|
119
|
+
* SHAPE shared without forcing the text to be identical.
|
|
120
|
+
*/
|
|
121
|
+
export const refusalMessage = (refusal: { readonly cause: string; readonly fix: string }): string =>
|
|
122
|
+
`${refusal.cause} — ${refusal.fix}`;
|
|
123
|
+
|
|
114
124
|
export function errorResponse(
|
|
115
125
|
id: JsonRpcId,
|
|
116
126
|
code: number,
|