@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 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.1",
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.1",
35
- "@ultimat3/core": "19.3.1",
36
- "@ultimat3/entity": "19.3.1",
37
- "@ultimat3/http": "19.3.1",
38
- "@ultimat3/jobs": "19.3.1",
39
- "@ultimat3/policy": "19.3.1",
40
- "@ultimat3/query": "19.3.1",
41
- "@ultimat3/schema": "19.3.1"
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: 'call tools/list to read the catalog this caller may use',
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(body: unknown, caller: McpCaller): Promise<JsonRpcResponse | null> {
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 errorResponse(null, INVALID_REQUEST, 'not a JSON-RPC 2.0 request envelope');
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`. */
@@ -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: input.path ?? '/mcp',
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
- null,
147
- INVALID_REQUEST,
148
- `request body is at least ${read.over} bytes, limit is ${bodyLimitBytes}`,
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
- const response = await server.handle(body, caller);
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
@@ -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
- /** Answered once per over-long message, and the `fix` is what the peer has to change. */
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
- return errorResponse(
98
- null,
99
- INVALID_REQUEST,
100
- `a single message exceeded ${limit} characters and was dropped`,
101
- {
102
- limit,
103
- fix: `send one JSON-RPC message per line, each under ${limit} characters — split a large tool result into paged calls`,
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,