@cyanheads/mcp-ts-core 0.13.9 → 0.13.10
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/AGENTS.md +36 -13
- package/CLAUDE.md +36 -13
- package/README.md +9 -9
- package/changelog/0.13.x/0.13.10.md +118 -0
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +11 -0
- package/dist/config/index.js.map +1 -1
- package/dist/core/app.d.ts.map +1 -1
- package/dist/core/app.js +15 -1
- package/dist/core/app.js.map +1 -1
- package/dist/core/context.d.ts +89 -20
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +40 -0
- package/dist/core/context.js.map +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/worker.d.ts +6 -0
- package/dist/core/worker.d.ts.map +1 -1
- package/dist/core/worker.js +1 -0
- package/dist/core/worker.js.map +1 -1
- package/dist/linter/rules/error-contract-rules.d.ts +3 -44
- package/dist/linter/rules/error-contract-rules.d.ts.map +1 -1
- package/dist/linter/rules/error-contract-rules.js +8 -144
- package/dist/linter/rules/error-contract-rules.js.map +1 -1
- package/dist/linter/rules/index.d.ts +1 -1
- package/dist/linter/rules/index.d.ts.map +1 -1
- package/dist/linter/rules/index.js +1 -1
- package/dist/linter/rules/index.js.map +1 -1
- package/dist/linter/rules/resource-rules.d.ts.map +1 -1
- package/dist/linter/rules/resource-rules.js +1 -2
- package/dist/linter/rules/resource-rules.js.map +1 -1
- package/dist/linter/rules/tool-rules.d.ts.map +1 -1
- package/dist/linter/rules/tool-rules.js +1 -2
- package/dist/linter/rules/tool-rules.js.map +1 -1
- package/dist/mcp-server/handlerContext.d.ts +26 -13
- package/dist/mcp-server/handlerContext.d.ts.map +1 -1
- package/dist/mcp-server/handlerContext.js +32 -17
- package/dist/mcp-server/handlerContext.js.map +1 -1
- package/dist/mcp-server/inputRequired.d.ts +120 -8
- package/dist/mcp-server/inputRequired.d.ts.map +1 -1
- package/dist/mcp-server/inputRequired.js +177 -12
- package/dist/mcp-server/inputRequired.js.map +1 -1
- package/dist/mcp-server/prompts/prompt-registration.d.ts +10 -2
- package/dist/mcp-server/prompts/prompt-registration.d.ts.map +1 -1
- package/dist/mcp-server/prompts/prompt-registration.js +49 -11
- package/dist/mcp-server/prompts/prompt-registration.js.map +1 -1
- package/dist/mcp-server/resources/resource-registration.d.ts +4 -2
- package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
- package/dist/mcp-server/resources/resource-registration.js +6 -4
- package/dist/mcp-server/resources/resource-registration.js.map +1 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts +4 -3
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +42 -14
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
- package/dist/mcp-server/server.d.ts +9 -0
- package/dist/mcp-server/server.d.ts.map +1 -1
- package/dist/mcp-server/server.js +14 -13
- package/dist/mcp-server/server.js.map +1 -1
- package/dist/mcp-server/tools/tool-registration.d.ts +7 -3
- package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
- package/dist/mcp-server/tools/tool-registration.js +9 -5
- package/dist/mcp-server/tools/tool-registration.js.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +17 -9
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js +91 -50
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
- package/dist/mcp-server/transports/http/httpErrorHandler.d.ts.map +1 -1
- package/dist/mcp-server/transports/http/httpErrorHandler.js +7 -1
- package/dist/mcp-server/transports/http/httpErrorHandler.js.map +1 -1
- package/dist/testing/index.d.ts +17 -2
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +21 -7
- package/dist/testing/index.js.map +1 -1
- package/dist/types-global/errors.d.ts +18 -15
- package/dist/types-global/errors.d.ts.map +1 -1
- package/dist/utils/internal/error-handler/errorHandler.d.ts +5 -4
- package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
- package/dist/utils/internal/error-handler/errorHandler.js +9 -7
- package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
- package/dist/utils/internal/error-handler/types.d.ts +3 -1
- package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
- package/dist/utils/internal/performance.d.ts +4 -2
- package/dist/utils/internal/performance.d.ts.map +1 -1
- package/dist/utils/internal/performance.js +8 -6
- package/dist/utils/internal/performance.js.map +1 -1
- package/dist/utils/internal/telemetryMessages.d.ts +0 -1
- package/dist/utils/internal/telemetryMessages.d.ts.map +1 -1
- package/dist/utils/internal/telemetryMessages.js +0 -1
- package/dist/utils/internal/telemetryMessages.js.map +1 -1
- package/dist/utils/telemetry/attributes.d.ts +5 -4
- package/dist/utils/telemetry/attributes.d.ts.map +1 -1
- package/dist/utils/telemetry/attributes.js +5 -4
- package/dist/utils/telemetry/attributes.js.map +1 -1
- package/framework-skills/add-service/SKILL.md +3 -12
- package/framework-skills/add-test/SKILL.md +6 -3
- package/framework-skills/add-tool/SKILL.md +29 -33
- package/framework-skills/api-config/SKILL.md +2 -1
- package/framework-skills/api-context/SKILL.md +155 -40
- package/framework-skills/api-errors/SKILL.md +43 -47
- package/framework-skills/api-linter/SKILL.md +5 -29
- package/framework-skills/api-telemetry/SKILL.md +11 -7
- package/framework-skills/api-testing/SKILL.md +43 -11
- package/framework-skills/api-workers/SKILL.md +3 -1
- package/framework-skills/design-mcp-server/SKILL.md +5 -5
- package/framework-skills/field-test/SKILL.md +2 -2
- package/framework-skills/polish-docs-meta/SKILL.md +2 -2
- package/framework-skills/release-and-publish/SKILL.md +3 -3
- package/framework-skills/release-pr-review/SKILL.md +2 -2
- package/framework-skills/security-pass/SKILL.md +8 -7
- package/package.json +4 -3
- package/scripts/install-otel.ts +84 -0
- package/scripts/lint-packaging.ts +188 -27
- package/templates/.env.example +2 -0
- package/templates/AGENTS.md +5 -4
- package/templates/CLAUDE.md +5 -4
- package/templates/Dockerfile +67 -50
- package/templates/src/mcp-server/tools/definitions/echo.tool.ts +3 -8
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: api-context
|
|
3
3
|
description: >
|
|
4
|
-
Canonical reference for the unified `Context` object passed to every tool and resource handler in `@cyanheads/mcp-ts-core`. Covers the full interface, its `RequestContext` base, all sub-APIs (`ctx.log`, `ctx.state`, `ctx.requestInput`, `ctx.inputs`, `ctx.enrich`, `ctx.content`), and when to use each.
|
|
4
|
+
Canonical reference for the unified `Context` object passed to every tool and resource handler in `@cyanheads/mcp-ts-core`. Covers the full interface, its `RequestContext` base, all sub-APIs (`ctx.log`, `ctx.state`, `ctx.requestInput`, `ctx.inputs`, `ctx.clientCapabilities`, `ctx.enrich`, `ctx.content`), and when to use each.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "2.
|
|
7
|
+
version: "2.9"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -24,7 +24,7 @@ import type { Context } from '@cyanheads/mcp-ts-core';
|
|
|
24
24
|
|
|
25
25
|
interface Context extends RequestContext {
|
|
26
26
|
// Identity & tracing (inherited from RequestContext — see § RequestContext)
|
|
27
|
-
readonly requestId: string; //
|
|
27
|
+
readonly requestId: string; // Per request; returned on its errors as data.requestId
|
|
28
28
|
readonly timestamp: string; // ISO 8601 request start time
|
|
29
29
|
readonly tenantId?: string; // JWT 'tid' claim; 'default' for stdio and HTTP+MCP_AUTH_MODE=none
|
|
30
30
|
readonly sessionId?: string; // Mcp-Session-Id (HTTP stateful/auto); undefined elsewhere unless opted in
|
|
@@ -43,7 +43,11 @@ interface Context extends RequestContext {
|
|
|
43
43
|
|
|
44
44
|
// Multi-round-trip input — always present, both eras (see § ctx.requestInput)
|
|
45
45
|
readonly requestInput: RequestInputFn; // (spec, options?) => never — suspends and asks the caller
|
|
46
|
-
readonly inputs: ContextInputs; //
|
|
46
|
+
readonly inputs: ContextInputs; // the request's responses, limited to what the client declared
|
|
47
|
+
// What the client declared: the SDK-parsed `initialize` value (2025 era) or the
|
|
48
|
+
// request's envelope as sent (2026-07-28); undefined when no view exists
|
|
49
|
+
// (see § ctx.clientCapabilities)
|
|
50
|
+
readonly clientCapabilities: ClientCapabilities | undefined;
|
|
47
51
|
|
|
48
52
|
// List-changed / resource-updated notifications — wired in every handler ctx;
|
|
49
53
|
// delivery is request-scoped (see § list-changed notifications)
|
|
@@ -70,8 +74,9 @@ interface Context extends RequestContext {
|
|
|
70
74
|
// pushes a raw ContentBlock.
|
|
71
75
|
readonly content: ContentCollect;
|
|
72
76
|
|
|
73
|
-
//
|
|
74
|
-
//
|
|
77
|
+
// Contract resolver — always present (returns {} when no contract is attached or the
|
|
78
|
+
// reason is unknown), strictly typed on HandlerContext<R> against declared reasons.
|
|
79
|
+
// The framework already sends a declared hint with any failure carrying the reason.
|
|
75
80
|
recoveryFor(reason: string): { recovery: { hint: string } } | {};
|
|
76
81
|
}
|
|
77
82
|
```
|
|
@@ -82,7 +87,7 @@ interface Context extends RequestContext {
|
|
|
82
87
|
|
|
83
88
|
| Field | Always present | Source |
|
|
84
89
|
|:------|:--------------|:-------|
|
|
85
|
-
| `requestId` | Yes |
|
|
90
|
+
| `requestId` | Yes | The client's JSON-RPC id when it is a string, otherwise a generated `XXXXX-XXXXX` token. Every log record of the call carries it, and the framework returns it on the call's error envelope as `data.requestId` |
|
|
86
91
|
| `timestamp` | Yes | ISO 8601, request start |
|
|
87
92
|
| `tenantId` | Stdio and HTTP+`MCP_AUTH_MODE=none` (as `'default'`); JWT `tid` claim in HTTP+`jwt`/`oauth` | JWT / single-tenant default |
|
|
88
93
|
| `sessionId` | HTTP `stateful` / `auto` mode; undefined for stdio and stateless HTTP unless opted in | `Mcp-Session-Id` header (or server-minted) — see [§ `ctx.sessionId`](#ctxsessionid) |
|
|
@@ -94,7 +99,7 @@ interface Context extends RequestContext {
|
|
|
94
99
|
|
|
95
100
|
## `RequestContext` — the one canonical request shape
|
|
96
101
|
|
|
97
|
-
`Context extends RequestContext`. There is a single request-shape type; the handler-facing `Context` adds handler-only surfaces (`log`, `state`, `signal`, `requestInput`, `inputs`, `enrich`, `content`, `uri`) on top of it and redeclares none of the identity fields. A handler's `ctx` is therefore assignable anywhere a `RequestContext` is — services, storage, the framework logger — with no slice helper and no cast.
|
|
102
|
+
`Context extends RequestContext`. There is a single request-shape type; the handler-facing `Context` adds handler-only surfaces (`log`, `state`, `signal`, `requestInput`, `inputs`, `clientCapabilities`, `enrich`, `content`, `uri`) on top of it and redeclares none of the identity fields. A handler's `ctx` is therefore assignable anywhere a `RequestContext` is — services, storage, the framework logger — with no slice helper and no cast.
|
|
98
103
|
|
|
99
104
|
```ts
|
|
100
105
|
import { requestContextService, withExtra } from '@cyanheads/mcp-ts-core/utils';
|
|
@@ -327,7 +332,7 @@ Always present, on every transport and both protocol eras. A handler that needs
|
|
|
327
332
|
|
|
328
333
|
One code path serves both eras. A 2026-07-28 client fulfils the embedded requests and retries the call; for a 2025-era session the SDK's legacy shim fulfils the same returns by issuing real `elicitation/create` / `sampling/createMessage` / `roots/list` round trips and re-entering the handler itself.
|
|
329
334
|
|
|
330
|
-
**A 2025-era client that declared no matching capability is refused, with an envelope.** URL-mode elicitation needs `elicitation.url`, form-mode needs `elicitation.form` (a bare `elicitation: {}` satisfies it), sampling needs `sampling` — `sampling.tools` when the request carries `tools` / `toolChoice` — and `roots/list` needs `roots`. `ctx.requestInput` runs the check on the result it builds and throws the refusal instead of the signal, so it never reaches the wire and the handler fails where it stands — the execution measurement records it as a failed call, and each family's usual error path shapes it. A tool gets `isError` with `structuredContent.error.code = -32600` (`InvalidRequest`), `data.reason: 'client_capability_missing'`, and a `data.recovery.hint` naming the capability; a resource read gets the same code, reason, and hint through the JSON-RPC error envelope. A prompt's `generate` receives no `ctx`, so it has no `ctx.requestInput` to gate. The check runs on every round, so a handler that elicits first and samples second is gated again on the second. A return carrying only `requestState` asks the client for nothing and is never gated. On the 2026-07-28 leg the SDK owns this check and a violation surfaces as its `MissingRequiredClientCapabilityError` (`-32021`) instead.
|
|
335
|
+
**A 2025-era client that declared no matching capability is refused, with an envelope.** URL-mode elicitation needs `elicitation.url`, form-mode needs `elicitation.form` (a bare `elicitation: {}` satisfies it), sampling needs `sampling` — `sampling.tools` when the request carries `tools` / `toolChoice` — and `roots/list` needs `roots`. `ctx.requestInput` runs the check on the result it builds and throws the refusal instead of the signal, so it never reaches the wire and the handler fails where it stands — the execution measurement records it as a failed call, its `Error in tool:<name>` record logs at `notice` (a property of the client's connection, not a server fault), and each family's usual error path shapes it. A tool gets `isError` with `structuredContent.error.code = -32600` (`InvalidRequest`), `data.reason: 'client_capability_missing'`, and a `data.recovery.hint` naming the capability; a resource read gets the same code, reason, and hint through the JSON-RPC error envelope. A prompt's `generate` receives no `ctx`, so it has no `ctx.requestInput` to gate. The check runs on every round, so a handler that elicits first and samples second is gated again on the second. A return carrying only `requestState` asks the client for nothing and is never gated. On the 2026-07-28 leg the SDK owns this check and a violation surfaces as its `MissingRequiredClientCapabilityError` (`-32021`) instead.
|
|
331
336
|
|
|
332
337
|
**The refusal's hint ends at reconnecting** — ``Reconnect with a client that declares the `elicitation.form` capability.`` — and offers no other way to supply the answer, because a consent gate deliberately has no input field for it: the model would fill it in. A handler whose own arguments can stand in for the answer says so per call with the optional second argument, a sentence appended to the hint after a space:
|
|
333
338
|
|
|
@@ -340,6 +345,8 @@ return ctx.requestInput(
|
|
|
340
345
|
|
|
341
346
|
The option shapes that refusal alone, on a tool call and a resource read alike. A connection that can serve the request never sees it, and the 2026-07-28 leg's `-32021` is untouched.
|
|
342
347
|
|
|
348
|
+
**Responses of a kind the client never declared never reach `ctx.inputs`, on either era.** The SDK lifts `inputResponses` off every client request — a first call and a 2025-era request included — so a client can arrive pre-answered with nothing having asked. The framework keeps only what the client's declared capabilities cover, at the same mode level the refusal above applies to requests: an elicit result carrying `content` is a form-mode answer and needs `elicitation.form` (a bare `elicitation: {}` counts), while one without — a URL-mode accept, a decline, a cancel — needs `elicitation` in any mode; a sampling result needs `sampling`, and `sampling.tools` when its content holds a `tool_use` or `tool_result` block; a roots result needs `roots`. An entry of no recognizable kind is dropped, and a request with no capability view — a 2025-era request under `MCP_SESSION_MODE=stateless` — carries none. A form gate facing a client without `elicitation.form` — a URL-only client included — therefore asks, and is refused, instead of acting on an answer nobody was shown. Legitimate rounds are untouched: the 2025 shim only issues requests the connection declared, and on 2026-07-28 the SDK refuses an embedded request the envelope does not cover. A client that *did* declare the capability can still pre-answer — which is what the consent record below is for.
|
|
349
|
+
|
|
343
350
|
**`MCP_SESSION_MODE` decides whether that second leg exists.** Under `stateful` / `auto` the shim has the session it needs. Under `stateless` each 2025-era request is served by a fresh instance that never saw `initialize`, so its client-capability view is empty and the round trip is refused rather than attempted — fail-closed, but the handler never gets its answer. The refusal carries the same envelope, with a message and hint that name the per-request case and point at a stateful session. Ship `stateless` on a server whose destructive tools gate on `ctx.requestInput` and those tools become unusable for v1 HTTP clients. 2026-07-28 clients are unaffected in either mode: that revision has no server→client request channel at all, which is precisely why `input_required` exists. stdio is unaffected in either mode.
|
|
344
351
|
|
|
345
352
|
**Declare the requirement rather than documenting it.** `createApp({ sessionMode: { default: 'stateful', require: 'stateful' } })` seeds the mode from code and refuses to start over HTTP when the resolved mode is `stateless`, so the incompatibility surfaces at boot instead of at the first refused confirmation. `MCP_SESSION_MODE` still wins over the default; the requirement is what an operator cannot silently override. Nothing derives this from handler code — `ctx.requestInput` is present on every transport and both eras, so whether a server needs a live session is a decision its author makes. Full precedence and error shape: `api-config` § Session mode.
|
|
@@ -352,36 +359,98 @@ Read `ctx.inputs` first, request only what is still missing, and write the call
|
|
|
352
359
|
import { inputRequired, tool, z } from '@cyanheads/mcp-ts-core';
|
|
353
360
|
import { validationError } from '@cyanheads/mcp-ts-core/errors';
|
|
354
361
|
|
|
355
|
-
const
|
|
356
|
-
|
|
362
|
+
const Format = z.object({ format: z.enum(['json', 'csv']).describe('Export format') });
|
|
363
|
+
|
|
364
|
+
export const exportReport = tool('export_report', {
|
|
365
|
+
description: 'Export a report, asking for the format when the caller left it out.',
|
|
366
|
+
input: z.object({
|
|
367
|
+
reportId: z.string().describe('Report to export'),
|
|
368
|
+
format: z.enum(['json', 'csv']).optional().describe('Export format; asked for when omitted'),
|
|
369
|
+
}),
|
|
370
|
+
output: z.object({ url: z.string().describe('Download URL') }),
|
|
371
|
+
|
|
372
|
+
handler(input, ctx) {
|
|
373
|
+
// A declined or cancelled prompt is a dead end, not a round to retry —
|
|
374
|
+
// re-asking loops until the round budget runs out.
|
|
375
|
+
const view = ctx.inputs.view('format');
|
|
376
|
+
if (view.kind === 'elicit' && view.action !== 'accept') {
|
|
377
|
+
throw validationError(`User ${view.action} the format prompt.`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const format = input.format ?? ctx.inputs.accepted('format', Format)?.format;
|
|
381
|
+
if (!format) {
|
|
382
|
+
return ctx.requestInput(
|
|
383
|
+
{ inputRequests: { format: inputRequired.elicit({ message: 'Which format?', requestedSchema: Format }) } },
|
|
384
|
+
{ fallbackHint: 'Or call again with format supplied.' },
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
// `format` is narrowed here.
|
|
388
|
+
return { url: exportAs(input.reportId, format) };
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
`ctx.requestInput` returns `never`, so `return ctx.requestInput(...)` type-checks against any output type. Calling it as a bare statement works at runtime — and is the only option from a service-layer helper — but TypeScript will not narrow across it.
|
|
394
|
+
|
|
395
|
+
### Consent gates — redeem a server record
|
|
396
|
+
|
|
397
|
+
A destructive handler cannot take an accepted answer on `ctx.inputs` as proof the user was asked: a client that declared `elicitation` can send `inputResponses` on a call nothing prompted for, and any `requestState` — sealed or not — can be replayed within its lifetime. What proves the round is a record the server wrote when it asked. Store what the prompt confirmed in `ctx.state` under a random id — the operation, the authenticated caller, the target, and a hash of what the target holds — send only that id as `requestState`, and **redeem the record — read it and delete it — before anything else in the handler**. An unknown, used, or expired id, or a record naming another operation, caller, target, or content, is a fresh prompt, never a proceed.
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
import { randomUUID } from 'node:crypto';
|
|
401
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
402
|
+
import { inputRequired, tool, z } from '@cyanheads/mcp-ts-core';
|
|
403
|
+
import { validationError } from '@cyanheads/mcp-ts-core/errors';
|
|
404
|
+
|
|
405
|
+
const Confirm = z.object({ confirm: z.boolean().describe('Whether to proceed with the deletion.') });
|
|
406
|
+
const Consent = z.object({
|
|
407
|
+
operation: z.string().describe('Tool the record was minted for'),
|
|
408
|
+
clientId: z.string().describe('Authenticated client that was asked; empty without auth'),
|
|
409
|
+
subject: z.string().describe('Authenticated subject that was asked; empty without auth'),
|
|
410
|
+
target: z.string().describe('Path the user confirmed'),
|
|
411
|
+
contentHash: z.string().describe('Hash of what the path held when the user confirmed'),
|
|
357
412
|
});
|
|
358
413
|
|
|
359
414
|
export const deletePath = tool('delete_path', {
|
|
360
|
-
description: 'Delete a path after
|
|
415
|
+
description: 'Delete a path after the user confirms it.',
|
|
361
416
|
input: z.object({ path: z.string().describe('Path to delete') }),
|
|
362
417
|
output: z.object({ deleted: z.string().describe('The path that was deleted') }),
|
|
363
418
|
annotations: { destructiveHint: true },
|
|
364
419
|
|
|
365
|
-
handler(input, ctx) {
|
|
366
|
-
//
|
|
367
|
-
|
|
420
|
+
async handler(input, ctx) {
|
|
421
|
+
// 1. Redeem first: whatever this round carries, the record is spent now.
|
|
422
|
+
const id = ctx.inputs.state();
|
|
423
|
+
const record = id && /^[0-9a-f-]{36}$/.test(id) ? await ctx.state.get(`consent/${id}`, Consent) : null;
|
|
424
|
+
if (record) await ctx.state.delete(`consent/${id}`);
|
|
425
|
+
|
|
426
|
+
// 2. What this call would confirm: this operation, for this caller, on this target as it is now.
|
|
427
|
+
const expected = {
|
|
428
|
+
operation: 'delete_path',
|
|
429
|
+
clientId: ctx.auth?.clientId ?? '',
|
|
430
|
+
subject: ctx.auth?.sub ?? '',
|
|
431
|
+
target: input.path,
|
|
432
|
+
contentHash: await hashOf(input.path),
|
|
433
|
+
};
|
|
434
|
+
const matches = record !== null && isDeepStrictEqual(record, expected);
|
|
435
|
+
|
|
436
|
+
// 3. Only a matching record makes the answer on ctx.inputs mean anything.
|
|
368
437
|
const view = ctx.inputs.view('confirm');
|
|
369
|
-
if (view.kind === 'elicit' && view.action !== 'accept') {
|
|
370
|
-
throw validationError(`User ${view.action} the
|
|
438
|
+
if (matches && view.kind === 'elicit' && view.action !== 'accept') {
|
|
439
|
+
throw validationError(`User ${view.action} the deletion.`);
|
|
371
440
|
}
|
|
441
|
+
const answer = matches ? ctx.inputs.accepted('confirm', Confirm) : undefined;
|
|
372
442
|
|
|
373
|
-
|
|
443
|
+
// 4. No matching record, or no answer: ask, with a fresh record.
|
|
374
444
|
if (!answer) {
|
|
445
|
+
const fresh = randomUUID();
|
|
446
|
+
await ctx.state.set(`consent/${fresh}`, expected, { ttl: 600 });
|
|
375
447
|
return ctx.requestInput({
|
|
376
448
|
inputRequests: {
|
|
377
|
-
confirm: inputRequired.elicit({
|
|
378
|
-
message: `Delete ${input.path}?`,
|
|
379
|
-
requestedSchema: Confirm,
|
|
380
|
-
}),
|
|
449
|
+
confirm: inputRequired.elicit({ message: `Delete ${input.path}?`, requestedSchema: Confirm }),
|
|
381
450
|
},
|
|
451
|
+
requestState: fresh,
|
|
382
452
|
});
|
|
383
453
|
}
|
|
384
|
-
// `answer` is narrowed here.
|
|
385
454
|
if (!answer.confirm) throw validationError('Deletion not confirmed.');
|
|
386
455
|
|
|
387
456
|
return { deleted: remove(input.path) };
|
|
@@ -389,7 +458,33 @@ export const deletePath = tool('delete_path', {
|
|
|
389
458
|
});
|
|
390
459
|
```
|
|
391
460
|
|
|
392
|
-
|
|
461
|
+
- **Bind the record to the operation and the caller.** Without `operation`, an id minted by another consent-gated tool — or by a resource read's round, whose `operation` is the `ctx.uri.href` it read — confirms this one. Without `clientId` and `subject`, another user in the same tenant redeems an id they were handed: sealing binds the state to its principal only while `MCP_REQUEST_STATE_KEY` is set, and the record binds the caller either way. `ctx.state` is tenant-scoped already. On stdio and under `MCP_AUTH_MODE=none` both fields are empty — every caller is the same principal there.
|
|
462
|
+
- **Redeeming is single-use against a sequential replay, not against concurrent retries.** `ctx.state` has no atomic read-and-delete, so retries carrying one id at the same moment can each read the record before any delete lands — five concurrent retries on `filesystem` acted five times. Until an atomic `take` exists ([#593](https://github.com/cyanheads/mcp-ts-core/issues/593)), make an action that must not repeat idempotent per record (pass the record id as the upstream idempotency key), or accept that risk knowingly.
|
|
463
|
+
- **The record's storage must be shared by every instance a retry can reach.** A 2026-07-28 retry is a new request — under stateless HTTP, behind a load balancer, or on Workers it can land on another instance, which finds nothing in a process-local `in-memory` store and asks again (safe, but the user never gets through). Use `filesystem`, `supabase`, or `cloudflare-d1` there. Never `cloudflare-kv`: it is eventually consistent, so a retry served elsewhere may not see the record yet, and a replay can outrun the delete, which widens the race above. 2025-era rounds stay inside the process that asked.
|
|
464
|
+
- **Carrying the target in `requestState` and comparing it on re-entry is replayable.** Signing the state proves only that this server once minted it; the same sealed string confirms the same deletion again until it expires. Single-use has to be enforced on the server.
|
|
465
|
+
- **`ctx.clientCapabilities` is never a reason to skip the prompt.** A gate that proceeds when `elicitation` is absent is the bypass the gate exists to prevent; let the `client_capability_missing` refusal (2025 era) or `-32021` (2026-07-28) stand.
|
|
466
|
+
- **Declare `sessionMode: { default: 'stateful', require: 'stateful' }`** when the server also serves 2025-era clients over HTTP — see above.
|
|
467
|
+
|
|
468
|
+
### `ctx.clientCapabilities` — asking only when the client can answer
|
|
469
|
+
|
|
470
|
+
The capabilities the client declared for this request: on a 2025-era connection the SDK's parsed `initialize` value, on 2026-07-28 the request's own `io.modelcontextprotocol/clientCapabilities` envelope as sent. `{}` when it declared none; `undefined` when no view exists — a 2025-era request served per-request under `MCP_SESSION_MODE=stateless`. `extensions` carries declared extensions such as `io.modelcontextprotocol/ui`.
|
|
471
|
+
|
|
472
|
+
The two sources differ in shape. A bare `elicitation: {}` counts as declaring `elicitation.form` either way, but the SDK normalizes it while parsing `initialize`, so on a 2025-era connection it reads back as `{ elicitation: { form: {} } }`, and on 2026-07-28 as `{ elicitation: {} }`. A check for form mode that looks only at `elicitation.form` misses the bare 2026-07-28 declaration.
|
|
473
|
+
|
|
474
|
+
Use it for **optional** context, where a client that cannot answer should fall through to another source rather than fail the call — which a 2026-07-28 request cannot do otherwise, since its `-32021` is raised after the handler has returned. Read `ctx.inputs` first, so the retry does not ask again:
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
handler(input, ctx) {
|
|
478
|
+
const roots = ctx.inputs.view('roots');
|
|
479
|
+
if (roots.kind === 'roots') return fromRoots(roots.roots);
|
|
480
|
+
if (ctx.clientCapabilities?.roots) {
|
|
481
|
+
return ctx.requestInput({ inputRequests: { roots: inputRequired.listRoots() } });
|
|
482
|
+
}
|
|
483
|
+
return fromLaunchDirectory(); // the next source — no error, nothing sent
|
|
484
|
+
}
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
Never use it to decide whether to ask for consent (see above).
|
|
393
488
|
|
|
394
489
|
### Building the embedded requests
|
|
395
490
|
|
|
@@ -409,28 +504,33 @@ At least one of `inputRequests` or `requestState` must be supplied — the build
|
|
|
409
504
|
```ts
|
|
410
505
|
return ctx.requestInput({
|
|
411
506
|
inputRequests: { confirm: inputRequired.elicit({ message, requestedSchema: Confirm }) },
|
|
412
|
-
requestState:
|
|
507
|
+
requestState: jobId,
|
|
413
508
|
});
|
|
414
509
|
// Next round:
|
|
415
510
|
const state = ctx.inputs.state<string>();
|
|
416
511
|
```
|
|
417
512
|
|
|
418
|
-
`requestState` round-trips **through the client
|
|
513
|
+
`requestState` round-trips **through the client**. Keep it an opaque handle — an id for a record in `ctx.state` — rather than the data itself.
|
|
514
|
+
|
|
515
|
+
**Set `MCP_REQUEST_STATE_KEY` to seal it.** With the key (≥ 32 bytes) configured, the framework signs the string a handler returns — on tool, resource, and prompt results alike — into the SDK codec's envelope, bound to the request's authenticated `clientId`, `subject`, and `tenantId` and valid for 900 s, and every server instance verifies an echoed state before the handler runs. A forged, tampered, expired, other-principal, or other-key state is answered as `-32602` with `data.reason: 'invalid_request_state'` and never reaches the handler. Handlers change nothing: they still return a plain string, and `ctx.inputs.state()` returns that original string. Every instance a retry can reach — stateless replicas, Worker isolates, a restarted process — needs the same key; there is no per-process random key. Unset, no verifier runs and the handler reads whatever string the client sent. The sealed state is signed, not encrypted: the client can read the payload, so never put a secret in it.
|
|
419
516
|
|
|
420
|
-
|
|
517
|
+
Signed is not single-use: a sealed state still verifies every time it is replayed within 900 s, and it names no operation — a state one tool sealed verifies on a call to another. Anything that must happen once — a consent gate above all — redeems a server record bound to its operation.
|
|
421
518
|
|
|
422
|
-
|
|
519
|
+
### `ctx.inputs` — reading the request's responses
|
|
520
|
+
|
|
521
|
+
Populated once the client (or the legacy shim) has answered a `ctx.requestInput`, limited to the answers the client's declared capabilities cover — kind and mode, as described above.
|
|
423
522
|
|
|
424
523
|
| Member | Returns |
|
|
425
524
|
|:---|:---|
|
|
426
525
|
| `accepted(key, schema?)` | The accepted form-mode content for `key`, or `undefined` when the key is missing, the user declined or cancelled, the response was another kind, or (with a schema) validation failed |
|
|
427
526
|
| `view(key)` | Discriminated view of one entry: `{ kind: 'missing' }` \| `{ kind: 'elicit', action, content? }` \| `{ kind: 'sampling', result }` \| `{ kind: 'roots', roots }` |
|
|
428
|
-
| `state<T>()` |
|
|
527
|
+
| `state<T>()` | The `requestState` this round carried — the handler's own string, verified and unsealed when `MCP_REQUEST_STATE_KEY` is set, the raw wire string otherwise — or `undefined` when the round carried none |
|
|
429
528
|
| `dropped` | Keys the SDK dropped because the client sent a wrapped rather than a bare response object. Re-issue those requests instead of hard-failing |
|
|
430
|
-
| `responses` | The raw response map, for kinds the helpers don't cover |
|
|
529
|
+
| `responses` | The raw response map, for kinds the helpers don't cover — filtered the same way |
|
|
431
530
|
|
|
432
|
-
|
|
531
|
+
Three rules follow from what the SDK does *not* do:
|
|
433
532
|
|
|
533
|
+
- **A response is client-supplied, even from a capable client.** It can arrive on a call nothing asked, so it proves a prompt was answered only alongside a record the handler redeemed.
|
|
434
534
|
- **Responses are never re-validated against the schema the request advertised.** Pass the schema to `accepted(key, schema)` wherever the content matters, and treat every value as untrusted client input.
|
|
435
535
|
- **`undefined` from `accepted()` collapses five different outcomes into one.** Missing, declined, cancelled, wrong response kind, and schema-invalid are indistinguishable through it. Branch on `view(key)` when decline/cancel needs different handling from "not asked yet" — as above, re-issuing a request the user already declined just burns rounds.
|
|
436
536
|
|
|
@@ -444,6 +544,18 @@ const ctx = createMockContext({
|
|
|
444
544
|
});
|
|
445
545
|
```
|
|
446
546
|
|
|
547
|
+
`createMockContext({ clientCapabilities })` seeds `ctx.clientCapabilities` and applies the production filter to the seeded responses; omitted, `ctx.clientCapabilities` is `undefined` and nothing is filtered. Each mock context has its own `ctx.state`, so to drive a consent gate's second round, copy the record its first round stored into the second context before calling the handler. The record carries the caller, so seed the same `auth` (or none) on both:
|
|
548
|
+
|
|
549
|
+
```ts
|
|
550
|
+
const first = createMockContext();
|
|
551
|
+
const asked = await expectInputRequired(() => deletePath.handler(input, first));
|
|
552
|
+
const record = await first.state.get(`consent/${asked.requestState}`);
|
|
553
|
+
|
|
554
|
+
const ctx = createMockContext({ inputResponses: accept, requestState: asked.requestState });
|
|
555
|
+
await ctx.state.set(`consent/${asked.requestState}`, record);
|
|
556
|
+
await expect(deletePath.handler(input, ctx)).resolves.toEqual({ deleted: input.path });
|
|
557
|
+
```
|
|
558
|
+
|
|
447
559
|
**Convention:** only call `ctx.requestInput` from tool, resource, and prompt handlers — not from services.
|
|
448
560
|
|
|
449
561
|
---
|
|
@@ -616,12 +728,14 @@ The contract is opt-in. See `framework-skills/api-errors/SKILL.md` for the full
|
|
|
616
728
|
|
|
617
729
|
## `ctx.recoveryFor`
|
|
618
730
|
|
|
619
|
-
Always present on `Context`. Resolves the contract `recovery` for a given reason and returns the canonical wire shape `{ recovery: { hint } }`, ready to spread into `data`.
|
|
731
|
+
Always present on `Context`. Resolves the contract `recovery` for a given reason and returns the canonical wire shape `{ recovery: { hint } }`, ready to spread into `data`.
|
|
732
|
+
|
|
733
|
+
It is not what puts the hint on the wire. The handler factory fills `data.recovery.hint` from the matching `errors[]` entry for any failure that carries a declared reason and no hint of its own — a bare `ctx.fail('reason')` and a service throw alike — so a static hint needs no call here:
|
|
620
734
|
|
|
621
735
|
```ts
|
|
622
736
|
async handler(input, ctx) {
|
|
623
|
-
// Static recovery —
|
|
624
|
-
if (queue.full()) throw ctx.fail('queue_full'
|
|
737
|
+
// Static recovery — the framework fills the contract's hint onto the wire.
|
|
738
|
+
if (queue.full()) throw ctx.fail('queue_full');
|
|
625
739
|
|
|
626
740
|
// Dynamic recovery — interpolate runtime context, override the contract default.
|
|
627
741
|
if (!matched) throw ctx.fail('no_match', `No items for "${input.query}"`, {
|
|
@@ -630,6 +744,8 @@ async handler(input, ctx) {
|
|
|
630
744
|
}
|
|
631
745
|
```
|
|
632
746
|
|
|
747
|
+
Reach for `ctx.recoveryFor` when the hint has to ride the thrown error itself — a test asserting `data.recovery` on the handler's own throw — or when a site deliberately sends another entry's guidance.
|
|
748
|
+
|
|
633
749
|
### Signature
|
|
634
750
|
|
|
635
751
|
```ts
|
|
@@ -648,13 +764,11 @@ ctx.recoveryFor(reason: R): { recovery: { hint: string } }
|
|
|
648
764
|
| Unknown reason | Returns `{}` (TS prevents this for typed callers; runtime is loose for JS / stale contracts). |
|
|
649
765
|
| Declared reason | Returns `{ recovery: { hint: <contract.recovery> } }` — spread into `data`. |
|
|
650
766
|
| Override | Caller can override by spreading `recoveryFor` first then writing `recovery: { hint: '...' }` after — last write wins. |
|
|
651
|
-
| Service usage |
|
|
652
|
-
|
|
653
|
-
### Why opt-in resolution, not auto-population
|
|
767
|
+
| Service usage | A service throwing a factory error with `data: { reason }` gets the declared hint from the framework's fill; it needs no `ctx` for it. |
|
|
654
768
|
|
|
655
|
-
|
|
769
|
+
### The wire hint follows the reason
|
|
656
770
|
|
|
657
|
-
The
|
|
771
|
+
The contract is the single source of truth for the recovery hint, and the framework applies it where the failure leaves the handler: in the tool and resource factories, before the failure is logged and the envelope built, matched on `data.reason` alone. A throw-site `recovery` of any shape wins. The thrown `McpError` is never changed, so a handler-level test sees exactly what the throw site wrote; `runToolContract` applies the same fill, so a contract test sees the production envelope. The `≥5 words` lint rule on contract `recovery` is what makes the default worth sending.
|
|
658
772
|
|
|
659
773
|
---
|
|
660
774
|
|
|
@@ -824,7 +938,8 @@ Test content blocks with `getContentBlocks(ctx)` from `@cyanheads/mcp-ts-core/te
|
|
|
824
938
|
| `ctx.enrich` | `Enrich` | Always; typed on `HandlerContext<R, E>` when an `enrichment` block is declared |
|
|
825
939
|
| `ctx.content` | `ContentCollect` | Always — prepends image/audio blocks to `content[]`, never `structuredContent` |
|
|
826
940
|
| `ctx.requestInput` | `(spec, options?) => never` | Always — suspends the handler and asks the caller for more input; `options.fallbackHint` extends a 2025-era capability refusal's hint |
|
|
827
|
-
| `ctx.inputs` | `ContextInputs` | Always;
|
|
941
|
+
| `ctx.inputs` | `ContextInputs` | Always; carries only the answers the client's declared capabilities cover (kind and mode), none when no capability view exists |
|
|
942
|
+
| `ctx.clientCapabilities` | `ClientCapabilities \| undefined` | Always as a key; `{}` when the client declared none, `undefined` when no view exists (2025-era stateless HTTP) |
|
|
828
943
|
| `ctx.notifyResourceListChanged` | `function \| undefined` | Always in handler ctx; delivery request-scoped (see [§ list-changed notifications](#list-changed-notifications-ctxnotify)) |
|
|
829
944
|
| `ctx.notifyResourceUpdated` | `function \| undefined` | Always in handler ctx; limited to URIs the client subscribed to, through the listen filter (2026) or the subscribe registry (2025) |
|
|
830
945
|
| `ctx.notifyPromptListChanged` | `function \| undefined` | Always in handler ctx; delivery request-scoped |
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
McpError constructor, JsonRpcErrorCode reference, and error handling patterns for `@cyanheads/mcp-ts-core`. Use when looking up error codes, understanding where errors should be thrown vs. caught, or using ErrorHandler.tryCatch in services.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.19"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -64,16 +64,11 @@ export const fetchTool = tool('fetch_articles', {
|
|
|
64
64
|
|:--------|:---------|
|
|
65
65
|
| Compile time | `ctx.fail('typo')` is a TS error. Auto-completes declared reasons. |
|
|
66
66
|
| Runtime | `ctx.fail(reason, msg?, data?, options?)` builds an `McpError(contract.code, msg, { ...data, reason }, options)` — `data.reason` is auto-populated from the contract and cannot be overridden by caller-supplied data (spread first, then `reason` written last), so observers see a stable identifier. `options` accepts `{ cause }` for ES2022 error chaining. |
|
|
67
|
+
| Runtime (recovery) | A failure whose `data.reason` names a declared entry and carries no `data.recovery` gets `data.recovery.hint` set to the entry's `recovery` at the handler boundary — see below. |
|
|
67
68
|
| Lint (devcheck) | Each `code` validated against `JsonRpcErrorCode`. Reasons validated as snake_case + unique within contract. `recovery` validated as non-empty and ≥ 5 words. Build-time only — not invoked at server startup. |
|
|
68
|
-
| Lint (conformance) | If the handler `throw new McpError(JsonRpcErrorCode.X)` outside `ctx.fail`, conformance check warns when X isn't declared. The inverse is checked too: a declared reason no `ctx.fail` in the handler names warns as `error-contract-unthrown` (mark it `thrownBy: 'service'` when the service layer produces it)
|
|
69
|
+
| Lint (conformance) | If the handler `throw new McpError(JsonRpcErrorCode.X)` outside `ctx.fail`, conformance check warns when X isn't declared. The inverse is checked too: a declared reason no `ctx.fail` in the handler names warns as `error-contract-unthrown` (mark it `thrownBy: 'service'` when the service layer produces it). |
|
|
69
70
|
|
|
70
|
-
> **`recovery` is
|
|
71
|
-
|
|
72
|
-
#### `ctx.recoveryFor` — opt-in contract resolution
|
|
73
|
-
|
|
74
|
-
`ctx.recoveryFor(reason)` returns `{ recovery: { hint: <contract.recovery> } }` for a declared reason, ready to spread into `data`. Always available on `Context` (returns `{}` when no contract is attached or the reason is unknown — spread-safe with no optional chaining). On `HandlerContext<R>` it tightens to a typed signature constrained to the declared reason union.
|
|
75
|
-
|
|
76
|
-
Spreading it into the data object and passing it as the data argument are the same call — `ctx.fail` spreads whatever `data` it receives. Spread when the site carries other keys, pass it directly when it carries nothing else. **Forwarding is lint-enforced per throw site:** a `ctx.fail` site that carries neither the resolver nor its own `recovery` key warns as `error-contract-recovery-unforwarded`, because the declared hint then reaches neither client surface and an error-path test asserting `code` and `reason` still passes.
|
|
71
|
+
> **`recovery` is the wire default for its reason.** The contract `recovery` is required metadata documenting the agent's next move when this failure mode fires (a forcing function for thoughtful guidance — placeholders like "Try again." get flagged by the linter), and it is what the caller receives. When a failure whose `data.reason` names a declared entry reaches the tool or resource handler factory with no `data.recovery`, the factory sets `data.recovery.hint` to that entry's `recovery` before it logs the failure and builds the envelope, so the `Error in tool:<name>` record, `structuredContent.error.data`, and the `Recovery:` line in `content[]` carry the same hint. It matches on the reason alone — a bare `ctx.fail('reason')`, a service throwing `notFound(msg, { reason })`, and a declared reason raised through a factory with a different code all get it. A throw-site `recovery` always wins, whatever its shape. An undeclared reason, a tool without `errors[]`, a non-`McpError` throw, and a cancelled call get nothing, and the framework-owned `invalid_arguments` / `client_capability_missing` refusals keep their own hints. The thrown `McpError` is never changed — a handler-level test of `ctx.fail` sees exactly what the throw site wrote — and `runToolContract` applies the same fill, so a contract test sees the production envelope. Prompts declare no contract.
|
|
77
72
|
|
|
78
73
|
```ts
|
|
79
74
|
export const calculateTool = tool('calculate', {
|
|
@@ -85,32 +80,29 @@ export const calculateTool = tool('calculate', {
|
|
|
85
80
|
],
|
|
86
81
|
handler(input, ctx) {
|
|
87
82
|
if (!input.expression.trim()) {
|
|
88
|
-
// Static recovery —
|
|
89
|
-
throw ctx.fail('empty_expression'
|
|
83
|
+
// Static recovery — the framework fills the contract's hint onto the wire.
|
|
84
|
+
throw ctx.fail('empty_expression');
|
|
90
85
|
}
|
|
91
86
|
// ...
|
|
92
87
|
},
|
|
93
88
|
});
|
|
94
89
|
```
|
|
95
90
|
|
|
96
|
-
Same
|
|
91
|
+
Same for a service, which needs no `ctx` to get the hint — the reason is enough:
|
|
97
92
|
|
|
98
93
|
```ts
|
|
99
94
|
export class MathService {
|
|
100
|
-
parse(expr: string
|
|
95
|
+
parse(expr: string) {
|
|
101
96
|
try {
|
|
102
97
|
return mathjs.parse(expr);
|
|
103
98
|
} catch (err) {
|
|
104
|
-
throw validationError(`Parse failed: ${err.message}`, {
|
|
105
|
-
reason: 'parse_failed',
|
|
106
|
-
...ctx.recoveryFor('parse_failed'), // {} if calling tool has no matching reason
|
|
107
|
-
});
|
|
99
|
+
throw validationError(`Parse failed: ${err.message}`, { reason: 'parse_failed' });
|
|
108
100
|
}
|
|
109
101
|
}
|
|
110
102
|
}
|
|
111
103
|
```
|
|
112
104
|
|
|
113
|
-
The contract is the single source of truth — write the recovery once, lint validates ≥5 words, the
|
|
105
|
+
The contract is the single source of truth — write the recovery once, lint validates ≥5 words, and the framework carries it to every failure with that reason. For runtime-context recovery (interpolating input values, attempted IDs, queue state), override at the throw site:
|
|
114
106
|
|
|
115
107
|
```ts
|
|
116
108
|
throw ctx.fail('no_match', `No item ${id}`, {
|
|
@@ -120,7 +112,11 @@ throw ctx.fail('no_match', `No item ${id}`, {
|
|
|
120
112
|
|
|
121
113
|
> **A recovery hint names a capability, never an internal method.** The reader is a model whose only reachable surface is this server's tool names — it cannot call a TypeScript method, set a library option, or re-run an internal function. `Re-stage the table via registerTable()` is unfollowable and invites a hallucinated tool call; `Re-run the tool that produced this table to stage it again, or list the currently staged tables with this server's dataframe-describe tool` is actionable from where the reader sits. Name a condition the caller cannot observe — an option flag they never set — and the hint is noise for the same reason. The framework holds its own throws to this rule: the canvas SQL gate's rejections point at the dataframe-query and dataframe-describe capabilities rather than the provider methods behind them.
|
|
122
114
|
|
|
123
|
-
`ctx.recoveryFor`
|
|
115
|
+
#### `ctx.recoveryFor` — the entry's hint at the throw site
|
|
116
|
+
|
|
117
|
+
`ctx.recoveryFor(reason)` returns `{ recovery: { hint: <contract.recovery> } }` for a declared reason, ready to spread into `data`. Always available on `Context` (returns `{}` when no contract is attached or the reason is unknown — spread-safe with no optional chaining). On `HandlerContext<R>` it tightens to a typed signature constrained to the declared reason union.
|
|
118
|
+
|
|
119
|
+
It is not needed to put a declared hint on the wire — the fill above does that. Reach for it when the hint has to ride the thrown error itself: a test asserting `data.recovery` on the handler's own throw, or a site that deliberately sends another entry's guidance (`ctx.fail('a', msg, ctx.recoveryFor('b'))`), which the fill respects as authored.
|
|
124
120
|
|
|
125
121
|
#### `severity` — log a modeled outcome below `error`
|
|
126
122
|
|
|
@@ -138,12 +134,14 @@ Values are the logger's own level names below `error` — `debug`, `info`, `noti
|
|
|
138
134
|
|
|
139
135
|
| Surface | Under a declared severity |
|
|
140
136
|
|:--------|:--------------------------|
|
|
141
|
-
| The `Error in tool:<name>` log record | Emitted at the declared level. Same message, same structured fields. |
|
|
137
|
+
| The `Error in tool:<name>` log record | Emitted at the declared level. Same message, same structured fields, the stack included. |
|
|
142
138
|
| `mcp.errors.classified` | Gains an `mcp.error.severity` attribute. The `reason` itself never becomes a metric attribute. |
|
|
143
139
|
| `isError`, the JSON-RPC code, `structuredContent.error`, `content[]` | Byte-identical to the undeclared case. |
|
|
144
140
|
| Span status, `mcp.tool.calls`, `mcp.tool.duration`, `mcp.tool.errors` | Unchanged — the call still failed, and splitting those series would redefine what an error rate means. |
|
|
145
141
|
|
|
146
|
-
**Tools only.** Resolution happens in the tool handler factory, against the thrown error's `data.reason`. Resources declare `errors[]` but
|
|
142
|
+
**Tools only.** Resolution happens in the tool handler factory, against the thrown error's `data.reason` — the same reason-to-entry lookup that fills `data.recovery`. Resources declare `errors[]` but write no failure record, so the field is accepted there and inert. A reason thrown below the handler that the contract never declared, an entry with no `severity`, and a non-`McpError` throw all keep `error`. A cancelled request keeps its own `info`, stack-free path regardless.
|
|
143
|
+
|
|
144
|
+
**The framework's own refusals log at `notice`.** An argument rejection (`invalid_arguments`, raised only by the schema gate before the handler runs) and a `ctx.requestInput` the connection cannot serve (`client_capability_missing`) are routine caller or connection traffic, not server faults, so their `Error in tool:<name>` record — and the failure-payload record when `LOG_TOOL_FAILURE_PAYLOADS=true` — is emitted at `notice`, and `mcp.errors.classified` counts them with `mcp.error.severity: "notice"`. Nothing to declare; an `errors[]` entry naming either reason with its own `severity` still wins. The wire envelope and `mcp.tool.rejections` are unchanged, and a schema that wrongly rejects valid calls still shows per tool on `mcp.tool.rejections`.
|
|
147
145
|
|
|
148
146
|
**Skip the contract** for one-off internal tools or quick prototypes — `ctx` is plain `Context` (no `fail`) and you throw via [factories](#error-factories-fallback) directly. Behavior is identical at the wire; the contract just adds compile-time safety.
|
|
149
147
|
|
|
@@ -173,7 +171,7 @@ errors: [
|
|
|
173
171
|
]
|
|
174
172
|
```
|
|
175
173
|
|
|
176
|
-
The handler doesn't catch and re-throw — letting service errors bubble unchanged keeps "logic throws, framework catches" intact. The wire payload
|
|
174
|
+
The handler doesn't catch and re-throw — letting service errors bubble unchanged keeps "logic throws, framework catches" intact. The wire payload carries `code`, `data.reason`, and the declared entry's `recovery` as `data.recovery.hint` (filled at the handler boundary, whatever code the service picked), so clients can switch on reason without parsing message text. What's lost is lint-time enforcement that every reason is reachable; compensate with one wire-shape test per reason.
|
|
177
175
|
|
|
178
176
|
**Mark the entries the service produces.** `error-contract-unthrown` reads the handler body alone, so in a handler that mixes one local precondition with service-thrown reasons it flags each service reason as dead. Add `thrownBy: 'service'` to those entries:
|
|
179
177
|
|
|
@@ -186,18 +184,7 @@ errors: [
|
|
|
186
184
|
]
|
|
187
185
|
```
|
|
188
186
|
|
|
189
|
-
The field is lint-only metadata — nothing at runtime reads it, so the entry is typed, advertised, and thrown exactly as an unmarked one, and its reason stays in the `ctx.fail` / `ctx.recoveryFor` union. It suppresses the one rule that cannot see below the handler, and only for the entries it marks; the handler's own reasons keep being checked.
|
|
190
|
-
|
|
191
|
-
To carry the contract `recovery` from a service throw, accept `ctx` and spread the resolver:
|
|
192
|
-
|
|
193
|
-
```ts
|
|
194
|
-
throw validationError(message, {
|
|
195
|
-
reason: 'parse_failed',
|
|
196
|
-
...ctx.recoveryFor('parse_failed'), // {} when calling tool has no matching reason
|
|
197
|
-
});
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
`ctx.recoveryFor` is always present on `Context` (no-op when no contract), so services don't need to know which tool called them — the spread is safe either way.
|
|
187
|
+
The field is lint-only metadata — nothing at runtime reads it, so the entry is typed, advertised, and thrown exactly as an unmarked one (its `recovery` filled like any other), and its reason stays in the `ctx.fail` / `ctx.recoveryFor` union. It suppresses the one rule that cannot see below the handler, and only for the entries it marks; the handler's own reasons keep being checked.
|
|
201
188
|
|
|
202
189
|
---
|
|
203
190
|
|
|
@@ -395,28 +382,38 @@ Checked before common patterns. Cover: AWS exception names, HTTP status codes, D
|
|
|
395
382
|
| Layer | Pattern |
|
|
396
383
|
|:------|:--------|
|
|
397
384
|
| Tool/resource handlers | Throw `McpError` — no try/catch |
|
|
398
|
-
| Handler factory (tools) | Catches all errors, normalizes to `McpError`, sets `isError: true`, mirrors error across both client surfaces (see [Error-path parity](#error-path-parity)) |
|
|
399
|
-
| Handler factory (resources) | Catches and re-throws to the SDK, which routes through the JSON-RPC error envelope |
|
|
385
|
+
| Handler factory (tools) | Catches all errors, fills a declared `recovery`, normalizes to `McpError`, sets `isError: true`, adds `data.requestId`, mirrors error across both client surfaces (see [Error-path parity](#error-path-parity)) |
|
|
386
|
+
| Handler factory (resources) | Catches, fills a declared `recovery`, adds `data.requestId`, and re-throws to the SDK, which routes through the JSON-RPC error envelope |
|
|
387
|
+
| Prompt registration, HTTP transport | Log the failure, then answer the JSON-RPC error with the thrown `McpError`'s `data` plus `data.requestId` |
|
|
400
388
|
| Services/setup code | `ErrorHandler.tryCatch` for structured logging and wrapping (always rethrows — never swallows) |
|
|
401
389
|
|
|
402
390
|
### Error-path parity
|
|
403
391
|
|
|
404
|
-
MCP clients differ in which `CallToolResult` surface they forward to the agent. Tool errors mirror the success-path `format-parity` invariant — the text carries the message, the recovery hint,
|
|
392
|
+
MCP clients differ in which `CallToolResult` surface they forward to the agent. Tool errors mirror the success-path `format-parity` invariant — the text carries the message, the recovery hint, the two fields a caller branches on, and the request id, while the numeric `code` and `data.issues` stay JSON-only:
|
|
405
393
|
|
|
406
394
|
| Surface | Content | Read by |
|
|
407
395
|
|:--------|:--------|:--------|
|
|
408
|
-
| `content[]` | Text rendering: `Error: <message>`, then `Recovery: <hint>` when `data.recovery.hint` adds something the message does not already say, then `(reason <reason> · not retryable)` for whichever of `data.reason` / `data.retryable` is present | Claude Desktop and other format()-only clients |
|
|
409
|
-
| `structuredContent.error` | JSON `{ code, message, data? }` carrying the error code, message,
|
|
396
|
+
| `content[]` | Text rendering: `Error: <message>`, then `Recovery: <hint>` when `data.recovery.hint` adds something the message does not already say, then `(reason <reason> · not retryable · request <id>)` for whichever of `data.reason` / `data.retryable` / `data.requestId` is present | Claude Desktop and other format()-only clients |
|
|
397
|
+
| `structuredContent.error` | JSON `{ code, message, data? }` carrying the error code, message, any structured data from the thrown `McpError` or `ZodError`, and `data.requestId` | Claude Code and other structuredContent-only clients |
|
|
398
|
+
|
|
399
|
+
```text
|
|
400
|
+
Error: No data for 3 PMIDs
|
|
401
|
+
|
|
402
|
+
Recovery: Use pubmed_search_articles to discover valid PMIDs.
|
|
403
|
+
|
|
404
|
+
(reason no_match · not retryable · request UTFAC-QE0MB)
|
|
405
|
+
```
|
|
410
406
|
|
|
411
407
|
Important properties:
|
|
412
408
|
- **`_meta.error` is NOT emitted.** Error code/data live on `structuredContent.error` instead. Don't read `_meta.error` in clients or tests — it doesn't exist.
|
|
413
|
-
- **`data` propagation is restricted** to explicitly-thrown `McpError.data
|
|
409
|
+
- **`data` propagation is restricted** to explicitly-thrown `McpError.data`, `ZodError.issues`, and the request id. Auto-classified plain errors (`TypeError`, network errors, etc.) emit `code`, `message`, and `data: { requestId }` only, so internal classification context never leaks to clients.
|
|
410
|
+
- **`data.requestId` names the request.** The framework sets it on every error envelope it builds — a tool result (handler throws, argument rejections, auth refusals, output-contract failures), a failed resource read, a failed prompt, and the JSON-RPC errors `httpErrorHandler` returns — to the `requestId` that call's log records carry. On a tool, resource, or prompt call that is a generated `XXXXX-XXXXX` token, or the client's JSON-RPC id when that id is a string; `httpErrorHandler` generates its own token, the one on its `Client error:` record. A failure reported from the client resolves to its `Error in tool:<name>` record by that value. A resource read refused before it is measured (an auth refusal, or URI variables that fail `params`) carries an id no log record shares, since resources write no failure record of their own. It is added where the envelope is built, never to the thrown `McpError.data`, so `ErrorHandler.handleError` / `tryCatch` results and the log record's `errorData` stay context-free; it replaces a thrown `data.requestId`, the way canonical fields win in log records. Two envelopes go without it: a resource `-32602` whose `data` is exactly `{ uri }` (the resource-not-found shape clients match exactly), and `runToolContract` results, which have no real request. It closes the `content[]` terms line, alone as `(request <id>)` when there is no `reason` or `retryable` — so a test pinning `content[0].text` exactly sees it.
|
|
414
411
|
- **Recovery hint mirroring is automatic, unless the hint repeats the message.** When the thrown `McpError` carries `data.recovery.hint`, the handler factory appends it to the `content[]` text so the markdown surface matches the JSON surface. Authors don't need to format the hint manually. The one exception is a hint the trimmed message already contains verbatim (case-sensitively) — an argument rejection whose every hint sentence restates an issue, where the hint is the message's issue text verbatim, and an author hint that restates its own message. There the line adds no next step, so it is dropped from the text; `structuredContent.error.data.recovery.hint` stays populated either way.
|
|
415
|
-
- **`reason` and `
|
|
412
|
+
- **`reason`, `retryable`, and `requestId` render as a trailing term line.** `(reason malformed_id · not retryable · request UTFAC-QE0MB)` closes the text whenever `data.reason` is a non-empty string, `data.retryable` is a boolean, or `data.requestId` is a non-empty string — `retryable` for `true`, `not retryable` for `false`, in that order. None present (an `McpError` with no `data` built outside a request, as `runToolContract` does) appends nothing at all. The numeric `code` and `data.issues` stay JSON-only on purpose: the code is the one envelope field a model cannot act on, and the message already renders each issue as a sentence. A consumer test pinning `content[0].text` exactly, rather than asserting it contains the diagnostic, therefore moves for any error carrying a reason.
|
|
416
413
|
- **Argument-schema rejection is a tool error with the same envelope.** An unknown root key, a wrong type, a missing required field, or a failed constraint returns `isError: true` with `structuredContent.error.code = -32602` (`InvalidParams`) and the readable `Invalid arguments for tool <name>: …` diagnostic in `content[]`. The handler never runs. Two neighbouring failures keep the protocol error path instead, arriving as a JSON-RPC error rather than a tool result: an unknown or disabled tool name, and a malformed request envelope.
|
|
417
|
-
- **`invalid_arguments` is the framework-owned reason on every argument rejection.** The rejection carries `data.reason: "invalid_arguments"` and a `data.recovery.hint` the framework synthesizes from the Zod issues, the arguments as sent, and the root schema — an unknown root key names the root properties the tool does accept, an unknown key inside a nested strict object names its full path and that object's own keys (`Unknown key opts.b. opts accepts: a.`, `Unknown key items.1.b. items.1 accepts: name.`), a wrong type names the type to send instead (a fractional number on an integer field reads `Send rows as an integer, not a fractional number.`), missing fields collapse into one `Provide …` sentence, and anything else restates its diagnostic line, field path included (`start: Must be a parseable ISO 8601 date`), so identical constraints on different fields stay distinguishable. When every sentence restates an issue, the hint is the message's issue text verbatim, and its `Recovery:` line is dropped from `content[]`. Beside a framework sentence each restatement is terminated and a repeated sentence appears once: `start: Must be a parseable ISO 8601 date. Send n as a number, not a string.` When pre-validation rewrote or dropped a key the caller wrote, the rejection says so, since its issues name only the keys that were validated: `data.input` carries `{ aliased: [{ alias, target }], ignored: [...] }` — keys only, in argument order, `ignored` holding the undeclared underscore-prefixed keys the drop discarded — and the hint closes with `Validated query as targetQuery.` / `Dropped undeclared key _max.`, framework sentences that keep the `Recovery:` line. An ignore-list drop (`_meta`, `toolCallId`, a server's `input.ignoreKeys`) is a client artifact and is never reported, and a rejection the step changed nothing on carries no `data.input`. The reason renders
|
|
418
|
-
- **`client_capability_missing` is the other framework-owned reason.** When a handler returns `ctx.requestInput({ inputRequests: … })` on a 2025-era connection whose client declared no matching capability, `ctx.requestInput` throws this failure in place of the input-required signal, before anything reaches the wire. It is an ordinary handler throw from there on
|
|
419
|
-
- **A schema constraint cannot carry a *declared* reason.** Because the handler never runs, a rejection by `.max()`, `.regex()`, `.min()`, or any other Zod refinement bypasses `errors[]` entirely: it arrives as `InvalidParams` with `data.issues` under the framework's `invalid_arguments`, never the `reason` and authored `recovery` of a contract entry — so a caller has nothing tool-specific to branch on and gets only the schema-derived hint. Decide per constraint which surface it belongs on. A bound that is purely structural — the input is the wrong shape and no guidance beyond the diagnostic would help — belongs on the schema, where it also advertises itself in `inputSchema`. A bound a caller is expected to recover from belongs in the handler as `ctx.fail('reason', message
|
|
414
|
+
- **`invalid_arguments` is the framework-owned reason on every argument rejection.** The rejection carries `data.reason: "invalid_arguments"` and a `data.recovery.hint` the framework synthesizes from the Zod issues, the arguments as sent, and the root schema — an unknown root key names the root properties the tool does accept, an unknown key inside a nested strict object names its full path and that object's own keys (`Unknown key opts.b. opts accepts: a.`, `Unknown key items.1.b. items.1 accepts: name.`), a wrong type names the type to send instead (a fractional number on an integer field reads `Send rows as an integer, not a fractional number.`), missing fields collapse into one `Provide …` sentence, and anything else restates its diagnostic line, field path included (`start: Must be a parseable ISO 8601 date`), so identical constraints on different fields stay distinguishable. When every sentence restates an issue, the hint is the message's issue text verbatim, and its `Recovery:` line is dropped from `content[]`. Beside a framework sentence each restatement is terminated and a repeated sentence appears once: `start: Must be a parseable ISO 8601 date. Send n as a number, not a string.` When pre-validation rewrote or dropped a key the caller wrote, the rejection says so, since its issues name only the keys that were validated: `data.input` carries `{ aliased: [{ alias, target }], ignored: [...] }` — keys only, in argument order, `ignored` holding the undeclared underscore-prefixed keys the drop discarded — and the hint closes with `Validated query as targetQuery.` / `Dropped undeclared key _max.`, framework sentences that keep the `Recovery:` line. An ignore-list drop (`_meta`, `toolCallId`, a server's `input.ignoreKeys`) is a client artifact and is never reported, and a rejection the step changed nothing on carries no `data.input`. The reason renders on the closing `(reason invalid_arguments · request <id>)`; this path sets no `retryable`. Its `Error in tool:<name>` record logs at `notice`, not `error`. Authors declare nothing for this: the rejection happens before the handler and the hint is derived from the schema.
|
|
415
|
+
- **`client_capability_missing` is the other framework-owned reason.** When a handler returns `ctx.requestInput({ inputRequests: … })` on a 2025-era connection whose client declared no matching capability, `ctx.requestInput` throws this failure in place of the input-required signal, before anything reaches the wire. It is an ordinary handler throw from there on, logged at `notice`: measured as the failed call it is, and shaped by the family's usual error path — a tool gets `structuredContent.error.code = -32600` (`InvalidRequest`), `data.reason: "client_capability_missing"`, and a `data.recovery.hint` naming the capability; a resource read gets the same code, reason, and hint through the JSON-RPC error envelope. The hint ends at reconnecting with a client that declares the capability, since a consent gate has no argument that could stand in for its answer; a handler whose arguments can appends its own sentence per call with `ctx.requestInput(spec, { fallbackHint })`. Like `invalid_arguments`, a definition cannot declare it in `errors[]`: it names a property of the connection, not a domain outcome. See `api-context`'s `ctx.requestInput`.
|
|
416
|
+
- **A schema constraint cannot carry a *declared* reason.** Because the handler never runs, a rejection by `.max()`, `.regex()`, `.min()`, or any other Zod refinement bypasses `errors[]` entirely: it arrives as `InvalidParams` with `data.issues` under the framework's `invalid_arguments`, never the `reason` and authored `recovery` of a contract entry — so a caller has nothing tool-specific to branch on and gets only the schema-derived hint. Decide per constraint which surface it belongs on. A bound that is purely structural — the input is the wrong shape and no guidance beyond the diagnostic would help — belongs on the schema, where it also advertises itself in `inputSchema`. A bound a caller is expected to recover from belongs in the handler as `ctx.fail('reason', message)` against a declared `errors[]` entry, whose `recovery` the framework puts on the wire, with the limit restated in the field's `.describe()` so it is still visible before the call. Enforcing the same bound in both places is the trap: the schema wins, and the contract entry becomes unreachable while still reading as covered.
|
|
420
417
|
- **A rejected value never reaches the client.** The rendered sentence distinguishes an omitted field from a wrong one (`what: Missing required field. Expected one of "os"|"cpu"` rather than the invalid-option text), and a union renders the branch that says what would have been accepted instead of Zod's `Invalid input` placeholder. Both read the arguments in-process for the absent/present bit and the arriving type only — `data.issues` ships the Zod issues as-is, and no value the caller sent is copied onto them.
|
|
421
418
|
- **A union branch names its own field.** Each branch issue is prefixed with the path it names relative to that branch, so two alternatives differing only in which field they require stay distinguishable: `spec: kind: Invalid option: expected one of "x"|"y"; n: Invalid input: expected number, received undefined or other: Invalid input: expected string, received undefined`. Issues *within* one branch join on `; `, across branches on ` or `, and top-level issues on `, ` — three nestings, three separators. A scalar branch carries no path and renders as before. `data.issues` still ships the raw nested Zod issues, and `data.recovery.hint` restates the same line, field path included.
|
|
422
419
|
- **A one-or-many union renders like the field it wraps.** Once a union branch fails below its root, every branch whose only issue is a root type mismatch is dropped — for `z.union([z.array(Item), Item])` given a list, that is the object branch saying only that the value is an array. If one branch remains, its issues render and hint under the field's path exactly as they would on a non-union field: `items.1.name: Invalid input: expected string, received boolean`, hinted `Send items.1.name as a string, not a boolean.` A missing element field is hinted `Provide items.1.name.`, and the rule applies again at every nested level. When every branch fails at its root (`items: "x"`), all of them render, joined by ` or `. `data.issues` keeps Zod's single `invalid_union` issue.
|
|
@@ -470,7 +467,7 @@ const parsed = await ErrorHandler.tryCatch(
|
|
|
470
467
|
|
|
471
468
|
`tryCatch` always logs and rethrows — it never swallows errors. The `fn` argument may be synchronous or return a `Promise`; both are handled via `Promise.resolve(fn())`.
|
|
472
469
|
|
|
473
|
-
**The thrown error's `data` is wire-visible.** A handler that lets it propagate forwards it as `structuredContent.error.data` (tools) or JSON-RPC `error.data` (resources, prompts). It carries the caught `McpError`'s own `data`, `originalErrorName`, `originalMessage`, and `rootCause` (`{ name, message }`) — never a stack and never `context`: `originalStack`, the full `causeChain`, and every `context` field (`requestId`, `sessionId`, `traceId`, `tenantId`, `extra`, …) go to the log record only. A field the caller should act on belongs in the thrown `McpError`'s `data`, not in `context`.
|
|
470
|
+
**The thrown error's `data` is wire-visible.** A handler that lets it propagate forwards it as `structuredContent.error.data` (tools) or JSON-RPC `error.data` (resources, prompts). It carries the caught `McpError`'s own `data`, `originalErrorName`, `originalMessage`, and `rootCause` (`{ name, message }`) — never a stack and never `context`: `originalStack`, the full `causeChain`, and every `context` field (`requestId`, `sessionId`, `traceId`, `tenantId`, `extra`, …) go to the log record only. A field the caller should act on belongs in the thrown `McpError`'s `data`, not in `context`. (The handler factory adds the call's own `data.requestId` when it builds the envelope; that value never comes from `context` here.)
|
|
474
471
|
|
|
475
472
|
**Options** (`Omit<ErrorHandlerOptions, 'rethrow'>`):
|
|
476
473
|
|
|
@@ -497,7 +494,7 @@ const response = await fetch(url, { signal: ctx.signal });
|
|
|
497
494
|
if (!response.ok) {
|
|
498
495
|
throw await httpErrorFromResponse(response, {
|
|
499
496
|
service: 'NCBI', // included in message
|
|
500
|
-
data: { endpoint,
|
|
497
|
+
data: { endpoint }, // the framework adds data.requestId
|
|
501
498
|
});
|
|
502
499
|
}
|
|
503
500
|
```
|
|
@@ -570,7 +567,6 @@ The linter validates the structure of `errors[]` and (when present) cross-checks
|
|
|
570
567
|
| `error-contract-conformance` | warning | Handler throws a non-baseline code that isn't in the contract. Suggests adding it to `errors[]` so the contract is the canonical source of truth for declared failure modes. |
|
|
571
568
|
| `error-contract-prefer-fail` | warning | Handler throws a code that **is** in the contract directly (via factory or `new McpError`) instead of through `ctx.fail(reason, …)`. Encourages routing through the typed helper so observers see consistent `data.reason` values. |
|
|
572
569
|
| `error-contract-unthrown` | warning | A declared `reason` that no literal `ctx.fail('<reason>'` or `ctx.recoveryFor('<reason>'` in the handler names. Fires only when the handler already holds at least one literal `ctx.fail(`, and skips the definition entirely when either callee takes a non-literal first argument. Wire the throw, drop the entry, or mark it `thrownBy: 'service'`. |
|
|
573
|
-
| `error-contract-recovery-unforwarded` | warning | A literal `ctx.fail('<reason>', …)` site carrying neither `ctx.recoveryFor('<reason>')` nor its own `recovery` key, so the declared hint reaches neither client surface. One diagnostic per site; skips a site whose data argument the scan cannot read. |
|
|
574
570
|
|
|
575
571
|
### Baseline codes (auto-allowed)
|
|
576
572
|
|