@cyanheads/mcp-ts-core 0.13.4 → 0.13.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/AGENTS.md +4 -4
  2. package/CLAUDE.md +4 -4
  3. package/README.md +1 -1
  4. package/changelog/0.13.x/0.13.5.md +41 -0
  5. package/dist/linter/rules/error-contract-rules.d.ts +65 -3
  6. package/dist/linter/rules/error-contract-rules.d.ts.map +1 -1
  7. package/dist/linter/rules/error-contract-rules.js +138 -3
  8. package/dist/linter/rules/error-contract-rules.js.map +1 -1
  9. package/dist/linter/rules/index.d.ts +1 -1
  10. package/dist/linter/rules/index.d.ts.map +1 -1
  11. package/dist/linter/rules/index.js +1 -1
  12. package/dist/linter/rules/index.js.map +1 -1
  13. package/dist/linter/rules/resource-rules.d.ts.map +1 -1
  14. package/dist/linter/rules/resource-rules.js +4 -2
  15. package/dist/linter/rules/resource-rules.js.map +1 -1
  16. package/dist/linter/rules/tool-rules.d.ts.map +1 -1
  17. package/dist/linter/rules/tool-rules.js +4 -2
  18. package/dist/linter/rules/tool-rules.js.map +1 -1
  19. package/dist/mcp-server/handlerContext.d.ts +5 -2
  20. package/dist/mcp-server/handlerContext.d.ts.map +1 -1
  21. package/dist/mcp-server/handlerContext.js +6 -4
  22. package/dist/mcp-server/handlerContext.js.map +1 -1
  23. package/dist/mcp-server/inputRequired.d.ts +35 -2
  24. package/dist/mcp-server/inputRequired.d.ts.map +1 -1
  25. package/dist/mcp-server/inputRequired.js +116 -2
  26. package/dist/mcp-server/inputRequired.js.map +1 -1
  27. package/dist/mcp-server/resources/resource-registration.d.ts +2 -1
  28. package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
  29. package/dist/mcp-server/resources/resource-registration.js +4 -4
  30. package/dist/mcp-server/resources/resource-registration.js.map +1 -1
  31. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts +2 -1
  32. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
  33. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +5 -2
  34. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
  35. package/dist/mcp-server/server.d.ts.map +1 -1
  36. package/dist/mcp-server/server.js +11 -2
  37. package/dist/mcp-server/server.js.map +1 -1
  38. package/dist/mcp-server/tools/tool-registration.d.ts +2 -1
  39. package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
  40. package/dist/mcp-server/tools/tool-registration.js +4 -4
  41. package/dist/mcp-server/tools/tool-registration.js.map +1 -1
  42. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +14 -4
  43. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  44. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +83 -9
  45. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  46. package/dist/services/canvas/core/CanvasInstance.d.ts +4 -1
  47. package/dist/services/canvas/core/CanvasInstance.d.ts.map +1 -1
  48. package/dist/services/canvas/core/CanvasInstance.js +5 -0
  49. package/dist/services/canvas/core/CanvasInstance.js.map +1 -1
  50. package/dist/services/canvas/core/CanvasRegistry.d.ts +52 -1
  51. package/dist/services/canvas/core/CanvasRegistry.d.ts.map +1 -1
  52. package/dist/services/canvas/core/CanvasRegistry.js +79 -3
  53. package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
  54. package/dist/services/canvas/index.d.ts +1 -1
  55. package/dist/services/canvas/index.d.ts.map +1 -1
  56. package/dist/services/canvas/index.js +1 -1
  57. package/dist/services/canvas/index.js.map +1 -1
  58. package/dist/types-global/errors.d.ts +30 -0
  59. package/dist/types-global/errors.d.ts.map +1 -1
  60. package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
  61. package/dist/utils/internal/error-handler/errorHandler.js +13 -3
  62. package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
  63. package/dist/utils/internal/error-handler/mappings.d.ts +14 -1
  64. package/dist/utils/internal/error-handler/mappings.d.ts.map +1 -1
  65. package/dist/utils/internal/error-handler/mappings.js +19 -1
  66. package/dist/utils/internal/error-handler/mappings.js.map +1 -1
  67. package/dist/utils/internal/error-handler/types.d.ts +12 -1
  68. package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
  69. package/dist/utils/internal/performance.d.ts.map +1 -1
  70. package/dist/utils/internal/performance.js +4 -1
  71. package/dist/utils/internal/performance.js.map +1 -1
  72. package/dist/utils/network/fetchWithTimeout.d.ts +24 -4
  73. package/dist/utils/network/fetchWithTimeout.d.ts.map +1 -1
  74. package/dist/utils/network/fetchWithTimeout.js +10 -6
  75. package/dist/utils/network/fetchWithTimeout.js.map +1 -1
  76. package/dist/utils/network/httpError.d.ts +43 -4
  77. package/dist/utils/network/httpError.d.ts.map +1 -1
  78. package/dist/utils/network/httpError.js +53 -6
  79. package/dist/utils/network/httpError.js.map +1 -1
  80. package/dist/utils/telemetry/attributes.d.ts +8 -0
  81. package/dist/utils/telemetry/attributes.d.ts.map +1 -1
  82. package/dist/utils/telemetry/attributes.js +8 -0
  83. package/dist/utils/telemetry/attributes.js.map +1 -1
  84. package/framework-skills/add-tool/SKILL.md +7 -4
  85. package/framework-skills/api-canvas/SKILL.md +31 -14
  86. package/framework-skills/api-context/SKILL.md +4 -2
  87. package/framework-skills/api-errors/SKILL.md +35 -7
  88. package/framework-skills/api-linter/SKILL.md +45 -3
  89. package/framework-skills/api-telemetry/SKILL.md +18 -3
  90. package/framework-skills/api-utils/SKILL.md +4 -4
  91. package/framework-skills/design-mcp-server/SKILL.md +4 -2
  92. package/framework-skills/field-test/SKILL.md +2 -2
  93. package/package.json +1 -1
  94. package/templates/AGENTS.md +1 -1
  95. package/templates/CLAUDE.md +1 -1
@@ -4,7 +4,7 @@ description: >
4
4
  DataCanvas primitive reference — a Tier 3 SQL/analytical workspace for tabular MCP servers, backed by DuckDB. Use when registering tables from upstream APIs, running ad-hoc SQL across them, and exporting results. Covers the acquire → register → query → export flow, per-table TTL, the token-sharing pattern for multi-agent collaboration, env config, and Cloudflare Workers fail-closed behavior.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.2"
7
+ version: "2.3"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -79,9 +79,29 @@ A canvas is identified by an opaque 10-character URL-safe `canvasId` (~10¹⁸ k
79
79
  | **Existing id (own tenant)** | Resolves to that canvas, slides TTL forward, returns `isNew: false`. |
80
80
  | **Existing id (other tenant)** | Throws `NotFound` — uniform with unknown to avoid leaking existence across tenants. |
81
81
  | **Unknown id** | Throws `NotFound` (`data.reason: 'canvas_not_found'`) with a recovery hint to re-run the producing tool or re-check the id. |
82
+ | **Malformed id** | Throws `ValidationError` (`data.reason: 'canvas_id_malformed'`) before any lookup, with a hint naming the format. A value that cannot be an id is an input error; only a well-formed id that is absent is a lookup miss. |
83
+ | **Omitted, tenant at its cap** | Throws `RateLimited` (`data.reason: 'canvas_capacity_exhausted'`, `retryable: true`) carrying `tenantId`, `activeCount`, and `cap`. The hint leads with reusing an id the caller already holds — the one reclaim path present in every configuration. |
82
84
 
83
85
  When auth is enabled, the effective scope is the composite `(tenantId, canvasId)`. In `MCP_AUTH_MODE=none`, `tenantId` collapses to `'default'` and the canvasId is the only differentiator — entropy + TTL + the framework's rate limiter make brute-force discovery operationally infeasible. **Designed for public-data servers (BrAPI, OpenFEC, etc.). Don't put PII on a no-auth canvas.**
84
86
 
87
+ That collapse is also why the capacity hint reads the way it does: under `default` the occupied slots may belong to other callers, and a consumer's dataframe-drop tool is off by default, so "drop an unused canvas" is advice nobody can follow. The cap is reached only on the mint path, when `canvas_id` was omitted. The refusal keeps `-32003` and its HTTP 429 mapping; `data.reason` is what separates it from upstream throttling, including in the `mcp.tool.error_category` metric, where it files under `server` rather than `upstream`.
88
+
89
+ ### Advertising the id shape
90
+
91
+ `CanvasIdSchema` is exported from `@cyanheads/mcp-ts-core/canvas` — `z.string().regex(/^[A-Za-z0-9_-]{10}$/)` with a `.describe()` naming where an id comes from. A tool that declares its `canvas_id` field with it advertises the constraint in `inputSchema`, so a model sees the shape before it calls and an impossible value is rejected at argument validation rather than inside the handler:
92
+
93
+ ```ts
94
+ import { CanvasIdSchema } from '@cyanheads/mcp-ts-core/canvas';
95
+
96
+ input: z.object({
97
+ canvas_id: CanvasIdSchema.optional().describe(
98
+ 'Optional canvas ID from a prior call. Omit on first call to start a fresh canvas.',
99
+ ),
100
+ }),
101
+ ```
102
+
103
+ The two halves are independent. On a tool that adopts the shape, `"x"` fails as `InvalidParams` (-32602) with the framework's own `reason: 'invalid_arguments'` and a schema-derived hint, and the handler never runs — so `canvas_id_malformed` never fires there. It covers tools that have not adopted it and ids the registry receives from somewhere other than a validated argument, `importFrom`'s source id in particular. Adopting the shape does not change any existing server's advertised schema until that server adopts it.
104
+
85
105
  ---
86
106
 
87
107
  ## Lifecycle
@@ -148,7 +168,7 @@ await instance.registerTable('recent_fetch', rows, { ttlMs: 30 * 60 * 1000 });
148
168
 
149
169
  Run SQL across registered tables. Returns at most `rowLimit` rows (default 10 000). When the result exceeds `rowLimit`, the response carries `truncated: true` and `rowCount` reflects the number of materialized rows (not the full result set). For full result sets and exact counts, pass `registerAs` — the result is materialized as a new canvas table; the response carries a `preview` slice and the exact `rowCount`.
150
170
 
151
- Querying a table that does not exist throws `NotFound` (`data.reason: 'missing_table'`) with a recovery hint to re-run the tool that staged the table or list what is currently staged. This happens when a table has expired (per-table TTL), been dropped, or the name is mistyped. The error is `NotFound`, not `ValidationError` — agents should re-stage, not fix the SQL shape. An unknown or expired `canvas_id` fails the same way (`data.reason: 'canvas_not_found'`, with its own recovery hint) — thrown by `acquire()` and every canvas operation.
171
+ Querying a table that does not exist throws `NotFound` (`data.reason: 'missing_table'`) with a recovery hint to re-run the tool that staged the table or list what is currently staged. This happens when a table has expired (per-table TTL), been dropped, or the name is mistyped. The error is `NotFound`, not `ValidationError` — agents should re-stage, not fix the SQL shape. A well-formed but unknown or expired `canvas_id` fails the same way (`data.reason: 'canvas_not_found'`, with its own recovery hint) — thrown by `acquire()` and every canvas operation. An id that fails the format check is a different failure: `ValidationError` with `data.reason: 'canvas_id_malformed'`, raised before the lookup on each of the three entry points that take a caller-supplied id — `acquire`, `drop` (which previously reported it as a silent `false`), and `importFrom`'s source id.
152
172
 
153
173
  A `SELECT` that parses but fails to prepare for any other reason — a mistyped column, an unknown function, an invalid expression — throws `ValidationError` (`data.reason: 'invalid_sql'`) and preserves the DuckDB binder detail in `data.binderMessage` (e.g. `Referenced column "x" not found...`, often with a candidate suggestion). This is distinct from `non_select_statement`, reserved for statements that genuinely aren't `SELECT`s — here the shape is fine, so the agent should fix the named column or function.
154
174
 
@@ -311,7 +331,7 @@ A fetcher that spills and a query tool that runs SQL across what was spilled —
311
331
 
312
332
  ```ts
313
333
  import { tool, z } from '@cyanheads/mcp-ts-core';
314
- import { spillover } from '@cyanheads/mcp-ts-core/canvas';
334
+ import { CanvasIdSchema, spillover } from '@cyanheads/mcp-ts-core/canvas';
315
335
  import { getCanvas } from '@/services/canvas-accessor.js';
316
336
 
317
337
  /** Fetch an upstream dataset, inline a preview, spill the full result to a canvas table. */
@@ -322,10 +342,9 @@ export const fetchDataset = tool('fetch_dataset', {
322
342
  annotations: { readOnlyHint: true },
323
343
  input: z.object({
324
344
  query: z.string().describe('Upstream search/filter expression'),
325
- canvas_id: z
326
- .string()
327
- .optional()
328
- .describe('Canvas ID from a prior call. Omit to start fresh — the response returns a new one.'),
345
+ canvas_id: CanvasIdSchema.optional().describe(
346
+ 'Canvas ID from a prior call. Omit to start fresh — the response returns a new one.',
347
+ ),
329
348
  }),
330
349
  output: z.object({
331
350
  canvas_id: z.string().describe('Canvas ID — pass to dataframe_query or another fetch call'),
@@ -361,7 +380,7 @@ export const dataframeQuery = tool('dataframe_query', {
361
380
  description: 'Run a read-only SQL SELECT against tables staged on a canvas by fetch_dataset.',
362
381
  annotations: { readOnlyHint: true },
363
382
  input: z.object({
364
- canvas_id: z.string().describe('Canvas ID returned by fetch_dataset'),
383
+ canvas_id: CanvasIdSchema.describe('Canvas ID returned by fetch_dataset'),
365
384
  sql: z.string().describe('Read-only SELECT. Reference tables by the names fetch_dataset returned.'),
366
385
  }),
367
386
  output: z.object({
@@ -397,18 +416,16 @@ A domain-specific instance of the [minimum viable spillover server](#minimum-via
397
416
 
398
417
  ```ts
399
418
  import { tool, z } from '@cyanheads/mcp-ts-core';
419
+ import { CanvasIdSchema } from '@cyanheads/mcp-ts-core/canvas';
400
420
  import { getCanvas } from '@/services/canvas-accessor.js';
401
421
 
402
422
  export const fetchAndStage = tool('fetch_and_stage_germplasm', {
403
423
  description: 'Fetch germplasm matching a query and stage it on a DataCanvas for follow-up SQL.',
404
424
  input: z.object({
405
425
  query: z.string().describe('Search query'),
406
- canvas_id: z
407
- .string()
408
- .optional()
409
- .describe(
410
- 'Optional 10-char canvas ID returned from a prior call. Omit on first call to start a fresh canvas; the response will include a new canvas_id you can pass to subsequent calls or share with another agent.',
411
- ),
426
+ canvas_id: CanvasIdSchema.optional().describe(
427
+ 'Optional canvas ID returned from a prior call. Omit on first call to start a fresh canvas; the response will include a new canvas_id you can pass to subsequent calls or share with another agent.',
428
+ ),
412
429
  }),
413
430
  output: z.object({
414
431
  canvas_id: z.string().describe('Canvas ID — pass to subsequent tool calls'),
@@ -4,7 +4,7 @@ description: >
4
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.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.4"
7
+ version: "2.5"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -326,7 +326,9 @@ Always present, on every transport and both protocol eras. A handler that needs
326
326
 
327
327
  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.
328
328
 
329
- **`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. 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.
329
+ **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.
330
+
331
+ **`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.
330
332
 
331
333
  **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.
332
334
 
@@ -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.13"
7
+ version: "1.14"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -65,7 +65,7 @@ export const fetchTool = tool('fetch_articles', {
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
67
  | 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. |
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`. |
69
69
 
70
70
  > **`recovery` is opt-in resolution, not auto-population.** 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). It does **not** automatically appear in runtime `data.recovery.hint` — the framework never injects it without an explicit signal at the throw site. Authors opt in by spreading `ctx.recoveryFor('reason')` into the `data` argument, the same way `ctx.fail('reason')` opts into resolving the contract `code`. What the author types at the throw site is what flows to the wire, with no hidden transformation; the resolver is just a typed lookup keyed by the same `reason` the author already typed.
71
71
 
@@ -120,6 +120,29 @@ throw ctx.fail('no_match', `No item ${id}`, {
120
120
 
121
121
  `ctx.recoveryFor` is the first member of a planned **family of opt-in resolution helpers**. Future contract-bound fields (`troubleshootingFor`, `userMessageFor`, …) follow the same shape: single-purpose, spreadable wire-shape, `{}` fallback when not applicable.
122
122
 
123
+ #### `severity` — log a modeled outcome below `error`
124
+
125
+ An outcome a tool declares in `errors[]` is a modeled result, not an incident. A caller who answers no to a confirmation prompt, a lookup whose miss is an ordinary answer — logging those at `error` alongside upstream faults and bugs leaves the error stream unreadable at the level log-based alerting works on. `severity` moves that one record's level:
126
+
127
+ ```ts
128
+ errors: [
129
+ { reason: 'consent_declined', code: JsonRpcErrorCode.InvalidRequest,
130
+ when: 'The caller declined the confirmation prompt.', severity: 'notice',
131
+ recovery: 'Re-run the tool and confirm the prompt to proceed with the change.' },
132
+ ],
133
+ ```
134
+
135
+ Values are the logger's own level names below `error` — `debug`, `info`, `notice`, `warning`. Omitting the field keeps `error`, byte for byte, for every server that does not opt in.
136
+
137
+ | Surface | Under a declared severity |
138
+ |:--------|:--------------------------|
139
+ | The `Error in tool:<name>` log record | Emitted at the declared level. Same message, same structured fields. |
140
+ | `mcp.errors.classified` | Gains an `mcp.error.severity` attribute. The `reason` itself never becomes a metric attribute. |
141
+ | `isError`, the JSON-RPC code, `structuredContent.error`, `content[]` | Byte-identical to the undeclared case. |
142
+ | 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. |
143
+
144
+ **Tools only.** Resolution happens in the tool handler factory, against the thrown error's `data.reason`. Resources declare `errors[]` but re-throw for the SDK to log, 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.
145
+
123
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.
124
147
 
125
148
  > **Declare contracts inline on each tool, even when similar across tools.** The contract is part of the tool's documented public surface — reading one tool definition file should give the full picture (input, output, errors, handler, format). Don't extract a shared `errors[]` constant or contract module to deduplicate near-identical entries; per-tool repetition is the intended cost of locality, and dynamic `recovery` hints often need tool-specific runtime context anyway. If a code-cleanup pass suggests consolidating contracts, decline — the duplication is load-bearing for tool-def readability.
@@ -356,19 +379,21 @@ Checked before common patterns. Cover: AWS exception names, HTTP status codes, D
356
379
 
357
380
  ### Error-path parity
358
381
 
359
- MCP clients differ in which `CallToolResult` surface they forward to the agent. Tool errors mirror the success-path `format-parity` invariant — both surfaces carry the same payload:
382
+ 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, and the two fields a caller branches on, while the numeric `code` and `data.issues` stay JSON-only:
360
383
 
361
384
  | Surface | Content | Read by |
362
385
  |:--------|:--------|:--------|
363
- | `content[]` | Text rendering: `Error: <message>` (plus `Recovery: <hint>` when `data.recovery.hint` is present) | Claude Desktop and other format()-only clients |
386
+ | `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 |
364
387
  | `structuredContent.error` | JSON `{ code, message, data? }` carrying the error code, message, and any structured data from the thrown `McpError` or `ZodError` | Claude Code and other structuredContent-only clients |
365
388
 
366
389
  Important properties:
367
390
  - **`_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.
368
391
  - **`data` propagation is restricted** to explicitly-thrown `McpError.data` and `ZodError.issues`. Auto-classified plain errors (`TypeError`, network errors, etc.) emit `code` + `message` only — no `data` — so internal classification context never leaks to clients.
369
- - **Recovery hint mirroring is automatic.** 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.
392
+ - **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) — a constraint or refinement rejection, whose synthesized hint is the issue's own message, 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.
393
+ - **`reason` and `retryable` render as a trailing term line.** `(reason malformed_id · not retryable)` closes the text whenever `data.reason` is a non-empty string or `data.retryable` is a boolean — `retryable` for `true`, `not retryable` for `false`, and both terms when both are present. Neither field present (a classified plain `Error`, an `McpError` with no `data`) 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.
370
394
  - **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.
371
- - **`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 key names the root properties the tool does accept, a wrong type names the type to send instead, missing fields collapse into one `Provide …` sentence, and anything else carries its own diagnostic. The hint rides `content[]` as `Recovery: …` like any other, so format-only clients see it too. Authors declare nothing for this: the rejection happens before the handler and the hint is derived from the schema.
395
+ - **`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 key names the root properties the tool does accept, a wrong type names the type to send instead, missing fields collapse into one `Provide …` sentence, and anything else carries its own diagnostic. The hint rides `content[]` as `Recovery: …` like any other — dropped only when the message already contains it, which is what the fallback for a constraint or refinement issue produces. The reason renders as the closing `(reason invalid_arguments)`; this path sets no `retryable`. Authors declare nothing for this: the rejection happens before the handler and the hint is derived from the schema.
396
+ - **`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: 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. 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`.
372
397
  - **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, ctx.recoveryFor('reason'))` against a declared `errors[]` entry, 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.
373
398
  - **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.
374
399
  - **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` carries the same prefixed text.
@@ -454,12 +479,13 @@ if (!response.ok) {
454
479
 
455
480
  Captures the response body (truncated, configurable limit) and `Retry-After` header (stored as `data.retryAfter`) into `error.data`. The codes it produces line up with `withRetry`'s transient-code set, so retryable responses are retried automatically.
456
481
 
457
- > **`error.data` reaches the client.** It is forwarded to the MCP client as `structuredContent.error.data` (tool errors) or JSON-RPC `error.data` (resource errors). Upstream 401/403/422 responses sometimes echo token claims, internal user IDs, or schema validation hints — that text becomes client-visible. For sensitive endpoints, pass `captureBody: false` (or `bodyLimit: 0`) so the body stays out of `data`. Defaults remain `captureBody: true` because most upstreams return useful diagnostic text and silent dropping helps no one debug. The upstream **URL** defaults the other way and is omitted, since a request URL routinely carries user input, internal identifiers, or an API key in its query string; `includeUrl: true` puts the full `response.url` on `data.url`. The message names the host either way.
482
+ > **`error.data` reaches the client.** It is forwarded to the MCP client as `structuredContent.error.data` (tool errors) or JSON-RPC `error.data` (resource errors). Upstream 401/403/422 responses sometimes echo token claims, internal user IDs, or schema validation hints — that text becomes client-visible. For sensitive endpoints, pass `captureBody: false` (or `bodyLimit: 0`) so the body stays out of `data`. Defaults remain `captureBody: true` because most upstreams return useful diagnostic text and silent dropping helps no one debug. The upstream **URL** defaults the other way and is omitted, since a request URL routinely carries user input, internal identifiers, or an API key in its query string; `includeUrl: true` puts the full `response.url` on `data.url`. The message names the host either way. Response **headers** are opt-in the same way: `errorHeaders: ['x-request-id']` copies the named headers onto `data.headers` under lowercase keys, and everything selected is client-facing — never name a header that carries a credential, and note that a selected `Location` can itself carry a sensitive path, query, or token. `set-cookie` is never captured whatever the selector says.
458
483
 
459
484
  Full status table:
460
485
 
461
486
  | Status | Code |
462
487
  |:-------|:-----|
488
+ | 3xx | `InvalidRequest` — reachable under `redirect: 'manual'`, and outside `withRetry`'s transient set since re-issuing returns the same redirect |
463
489
  | 400 | `InvalidParams` |
464
490
  | 401 | `Unauthorized` |
465
491
  | 402, 403 | `Forbidden` |
@@ -510,6 +536,7 @@ The linter validates the structure of `errors[]` and (when present) cross-checks
510
536
  | `error-contract-recovery-empty` | error | `recovery` is empty/whitespace-only |
511
537
  | `error-contract-recovery-min-words` | warning | `recovery` has fewer than 5 words — placeholders like "Try again." or "Check input." get flagged in favor of specific guidance |
512
538
  | `error-contract-retryable-type` | warning | `retryable` is present but not a boolean |
539
+ | `error-contract-severity-unknown` | error | `severity` is present but isn't one of `debug` / `info` / `notice` / `warning`. It selects a logger method at runtime; omit the field for the default `error` level |
513
540
 
514
541
  ### Conformance rules
515
542
 
@@ -517,6 +544,7 @@ The linter validates the structure of `errors[]` and (when present) cross-checks
517
544
  |:-----|:---------|:--------|
518
545
  | `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. |
519
546
  | `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. |
547
+ | `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 any of them takes a non-literal first argument. Wire the throw, or drop the entry. |
520
548
 
521
549
  ### Baseline codes (auto-allowed)
522
550
 
@@ -4,7 +4,7 @@ description: >
4
4
  MCP definition linter rules reference. Use when `bun run lint:mcp` or `bun run devcheck` reports a lint error or warning (`format-parity`, `schema-is-object`, `name-format`, `server-json-*`, etc.) and you need to understand the rule, its severity, and how to fix it. Every rule ID the linter emits has an entry in this doc.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.15"
7
+ version: "1.16"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -52,8 +52,8 @@ Grouped by family. Jump to any rule ID via its anchor.
52
52
  | Landing | `landing-*` (23 rules — shape, tagline, logo, links, repo, envExample, connectSnippets, theme) | [Landing config rules](#landing-config-rules) |
53
53
  | Prompts | `generate-required` | [Prompt rules](#prompt-rules) |
54
54
  | Handler body | `prefer-mcp-error-in-handler`, `prefer-error-factory`, `preserve-cause-on-rethrow`, `no-stringify-upstream-error` | [Handler body rules](#handler-body-rules) |
55
- | Error contract (structural) | `error-contract-type`, `error-contract-empty`, `error-contract-entry-type`, `error-contract-code-type`, `error-contract-code-unknown`, `error-contract-code-unknown-error`, `error-contract-reason-required`, `error-contract-reason-format`, `error-contract-reason-unique`, `error-contract-when-required`, `error-contract-retryable-type`, `error-contract-recovery-required`, `error-contract-recovery-empty`, `error-contract-recovery-min-words` | [Error contract rules](#error-contract-rules) |
56
- | Error contract (conformance) | `error-contract-conformance`, `error-contract-prefer-fail` | [Error contract rules](#error-contract-rules) |
55
+ | Error contract (structural) | `error-contract-type`, `error-contract-empty`, `error-contract-entry-type`, `error-contract-code-type`, `error-contract-code-unknown`, `error-contract-code-unknown-error`, `error-contract-reason-required`, `error-contract-reason-format`, `error-contract-reason-unique`, `error-contract-when-required`, `error-contract-retryable-type`, `error-contract-severity-unknown`, `error-contract-recovery-required`, `error-contract-recovery-empty`, `error-contract-recovery-min-words` | [Error contract rules](#error-contract-rules) |
56
+ | Error contract (conformance) | `error-contract-conformance`, `error-contract-prefer-fail`, `error-contract-unthrown` | [Error contract rules](#error-contract-rules) |
57
57
  | Enrichment | `enrichment-type`, `enrichment-empty`, `enrichment-field-type`, `enrichment-output-collision`, `enrichment-prefer-block`, `enrichment-trailer-render`, `enrichment-trailer-orphan`, `enrichment-trailer-unknown-field`, `capped-list-no-truncation` | [Enrichment rules](#enrichment-rules) |
58
58
  | server.json | ~40 rules prefixed `server-json-*` | [server.json rules](#server-json-rules) |
59
59
 
@@ -815,6 +815,23 @@ Fires when an entry's `when` field is missing or empty. `when` is the human-read
815
815
 
816
816
  Fires when an entry's optional `retryable` field is present but isn't a boolean. Only `true` or `false` is meaningful — drop the field if you can't commit to either.
817
817
 
818
+ ### error-contract-severity-unknown
819
+
820
+ **Severity:** error
821
+
822
+ Fires when an entry's optional `severity` field is present but isn't one of `debug`, `info`, `notice`, or `warning`. Unlike `retryable`, this field is not inert metadata — it selects the logger method the failure's record is emitted through, so an unrecognized value has no runtime meaning.
823
+
824
+ `error` is not accepted: it is the default, expressed by omitting the field. Nor are the pino spellings (`warn`) or other cases (`WARNING`) — the values are the framework logger's own level names.
825
+
826
+ **Fix:** use one of the four levels, or drop the field.
827
+
828
+ ```ts
829
+ // instead of:
830
+ { reason: 'consent_declined', code: JsonRpcErrorCode.InvalidRequest, when: '…', severity: 'warn', recovery: '…' }
831
+ // use:
832
+ { reason: 'consent_declined', code: JsonRpcErrorCode.InvalidRequest, when: '…', severity: 'warning', recovery: '…' }
833
+ ```
834
+
818
835
  ### error-contract-recovery-required
819
836
 
820
837
  **Severity:** error
@@ -866,6 +883,31 @@ throw ctx.fail('no_match', 'No items match');
866
883
 
867
884
  The diagnostic message includes the declared reason(s) for the code so you can copy-paste.
868
885
 
886
+ ### error-contract-unthrown
887
+
888
+ **Severity:** warning
889
+
890
+ The inverse of `error-contract-conformance`. Fires when a declared `reason` has no literal `ctx.fail('<reason>'` and no literal `ctx.recoveryFor('<reason>'` anywhere in the handler — a contract entry no code path can produce.
891
+
892
+ A dead entry compiles and lints clean: the typed `ctx.fail` union accepts the reason, so nothing downstream objects. The cost lands on the client, which plans around the advertised failure surface — an agent prepares for a mode the tool cannot produce, while the mode it *does* produce goes undocumented.
893
+
894
+ **Fix:** wire the missing throw, or drop the entry. Which one is right is the author's call, so the rule surfaces and does not auto-remove.
895
+
896
+ ```ts
897
+ errors: [
898
+ { reason: 'no_match', code: JsonRpcErrorCode.NotFound, when: '…', recovery: '…' },
899
+ { reason: 'site_not_found', code: JsonRpcErrorCode.NotFound, when: '…', recovery: '…' },
900
+ ],
901
+ async handler(input, ctx) {
902
+ if (rows.length === 0) throw ctx.fail('no_match', 'No rows in range');
903
+ }
904
+ // warning error-contract-unthrown — 'site_not_found' is declared but never thrown.
905
+ ```
906
+
907
+ **Trigger.** Only when the handler holds at least one literal `ctx.fail(`. A handler with none produces its reasons somewhere the scan cannot reach, so firing there would warn on every service-layer definition. A `ctx.fail(` whose first argument is not a string literal — a variable, a template literal, a map lookup — makes the thrown set unknowable, and the whole definition is skipped rather than guessed at.
908
+
909
+ **Heuristic limitations:** the scan reads `handler.toString()` and matches call sites in the comment- and string-stripped text, so a `ctx.fail('…')` written inside a comment or nested in another literal does not count as thrown. A reason produced outside the handler closure is invisible to any `toString()` scan, which is why the rule can never prove absence and stays a warning. Silent under the trigger above: a service that throws a factory error carrying `data: { reason }`, a `createFail(errors)` resolver built outside the handler, and an aliased `const fail = ctx.fail`.
910
+
869
911
  ---
870
912
 
871
913
  ## Enrichment rules
@@ -4,7 +4,7 @@ description: >
4
4
  Catalog of OpenTelemetry instrumentation built into framework `@cyanheads/mcp-ts-core` — spans, metrics, completion logs, env config, runtime caveats, custom instrumentation patterns, and cardinality rules. Use when enabling OTel export, adding custom spans or metrics in services, debugging missing telemetry, looking up attribute names, or deciding what's safe to put on a metric attribute vs. a span.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.11"
7
+ version: "1.12"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -123,7 +123,7 @@ All custom metrics are namespaced `mcp.*` (or `process.*` / `http.client.*` wher
123
123
  |:-------|:-----|:-----|:-----------|
124
124
  | `mcp.tool.calls` | counter | `{calls}` | `mcp.tool.name`, `mcp.tool.success` |
125
125
  | `mcp.tool.duration` | histogram | `ms` | `mcp.tool.name`, `mcp.tool.success` |
126
- | `mcp.tool.errors` | counter | `{errors}` | `mcp.tool.name`, `mcp.tool.error_category` (`upstream`/`server`/`client`) |
126
+ | `mcp.tool.errors` | counter | `{errors}` | `mcp.tool.name`, `mcp.tool.error_category` (`upstream`/`server`/`client`) — see [Error category](#error-category) |
127
127
  | `mcp.tool.input_bytes` | histogram | `bytes` | `mcp.tool.name` |
128
128
  | `mcp.tool.output_bytes` | histogram | `bytes` | `mcp.tool.name` (success only; the handler's returned value) |
129
129
  | `mcp.tool.param.usage` | counter | `{uses}` | `mcp.tool.name`, `mcp.tool.param` (top-level keys supplied by caller) |
@@ -192,11 +192,26 @@ Read together: `queue_depth` rising while `wait` climbs means the configured rat
192
192
  | `mcp.sessions.active` | observable gauge | `{sessions}` | — |
193
193
  | `mcp.heartbeat.failures` | counter | `{failures}` | `mcp.connection.transport` (`stdio`/`http`) |
194
194
 
195
+ ### Error category
196
+
197
+ `mcp.tool.error_category` and `mcp.prompt.error_category` bucket a failure as `upstream` (an external dependency refused or timed out), `server` (a bug or this process's own infrastructure), or `client` (the request itself). The bucket comes from the classified JSON-RPC code, with one refinement: `RateLimited` (`-32003`) legitimately carries two sources, so the canvas tenant-cap refusal — which names itself with `data.reason: 'canvas_capacity_exhausted'` — files under `server`, and every other `-32003` stays `upstream`. Retry semantics and the HTTP 429 mapping are the same for both, which is why the code is shared and the stable `reason` discriminator does the separating.
198
+
199
+ A dashboard reading `error_category` alone therefore no longer needs to special-case one server's capacity limit as an upstream outage. `reason` itself is not on the metric — it is unbounded across a fleet, so it lives on the span and in the log.
200
+
201
+ ### Declared error severity
202
+
203
+ A definition may put `severity` on an `errors[]` entry — `debug`, `info`, `notice`, or `warning` — for an outcome it models rather than suffers. Two things move, and nothing else:
204
+
205
+ - The `Error in tool:<name>` log record is emitted at that level instead of `error`, with the same message and structured fields.
206
+ - `mcp.errors.classified` gains `mcp.error.severity` on that record. It is set only when a declared severity resolved, so a server that declares none emits exactly the series it did before.
207
+
208
+ The call still failed: the execution span keeps `SpanStatusCode.ERROR` and its recorded exception, `mcp.tool.calls` / `mcp.tool.duration` / `mcp.tool.errors` record the same values, and the completion log still reads `isSuccess: false`. Splitting those series on an authoring decision would redefine what an error rate means. Tools only — resources re-throw for the SDK to log. A cancelled request keeps its own `info`, stack-free path whatever the contract declares. See `api-errors`.
209
+
195
210
  ### Errors, rate limits, HTTP client
196
211
 
197
212
  | Metric | Type | Unit | Attributes |
198
213
  |:-------|:-----|:-----|:-----------|
199
- | `mcp.errors.classified` | counter | `{errors}` | `mcp.error.classified_code` (JSON-RPC code), `operation` |
214
+ | `mcp.errors.classified` | counter | `{errors}` | `mcp.error.classified_code` (JSON-RPC code), `operation`, and `mcp.error.severity` when the failure's declared severity resolved |
200
215
  | `mcp.ratelimit.rejections` | counter | `{rejections}` | — (the rate-limit key is caller-supplied and typically per-client, so it would materialize an unbounded series in the meter; per-key attribution lives on the span instead) |
201
216
  | `http.client.request.duration` | histogram | `s` | `http.request.method`, `server.address`, `http.response.status_code` (when > 0; absent on network errors before a response is received) |
202
217
 
@@ -4,7 +4,7 @@ description: >
4
4
  API reference for all utilities exported from `@cyanheads/mcp-ts-core/utils`. Use when looking up utility method signatures, options, peer dependencies, or usage patterns.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.10"
7
+ version: "2.11"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -31,14 +31,14 @@ Utility exports from `@cyanheads/mcp-ts-core/utils`. Utilities with complex APIs
31
31
 
32
32
  | Export | API | Notes |
33
33
  |:-------|:----|:------|
34
- | `fetchWithTimeout` | `(url, timeoutMs, context, options?: FetchWithTimeoutOptions) -> Promise<Response>` | Wraps `fetch` with `AbortController` timeout. `timeoutMs` bounds the **whole exchange**: on a 2xx carrying a body the returned `Response` is a passthrough wrapper that keeps the deadline armed until the body closes, errors, or is cancelled, so a stalled stream rejects the caller's `.text()`/`.json()` with the same `Timeout` error the header phase raises. `status`, `statusText`, `headers`, `url`, `redirected`, and `type` carry across the wrapper; the original body is locked by it, and bodyless/null-body responses (HEAD, 204/205/304) come back untouched. `FetchWithTimeoutOptions` extends `RequestInit` (minus `signal`) and adds `rejectPrivateIPs?: boolean`, `expectedStatuses?: number[]` (listed non-2xx statuses logged at `debug` not `error`, still thrown), `errorBodyLimit?: number` (bytes of a non-2xx body kept, default `500`), and `signal?: AbortSignal` (external cancellation — an abort on it throws `RequestCancelled` (-32011), logged at `info` and outside `withRetry`'s transient set, since the caller is gone and no retry can reach them). On a non-2xx, `error.data` carries `status`/`body` plus the legacy `statusCode`/`responseBody` aliases (identical values; consolidating in a future major); a body over `errorBodyLimit` is captured from both ends — 40% head, 60% tail, joined by `…[N bytes elided]…` — so a diagnostic behind a boilerplate preamble survives the cap, while a body still streaming at the 16 KiB scan ceiling stays head-only with a trailing `…`. SSRF guard (best-effort, not hard isolation): blocks RFC 1918, loopback, link-local, CGNAT, cloud metadata. DNS validation on Node, Bun, and Cloudflare Workers under `nodejs_compat`; hostname-only fallback otherwise. **Both resolvers are queried** — `resolve4`/`resolve6` (c-ares) and `lookup` (the system resolver, which is what reads `/etc/hosts`, split DNS, and NSS modules) — and a non-global answer from either rejects. Runtimes differ in which resolver the connection uses (Bun 1.4 moved `net.connect()` on Linux to `getaddrinfo` while leaving `dns.resolve*()` on c-ares), so checking one alone leaves a name the other can see unguarded; each probe settles independently, so a resolver absent from the runtime is skipped rather than fatal. Manual redirect following (max 5) with per-hop SSRF check. **DNS rebinding / TOCTOU gap** — the validation lookup and `fetch`'s own resolution are independent; pair with egress controls or a DNS-pinning fetch proxy for strong isolation. **Error/log redaction:** URLs written into thrown errors and log lines are reduced to `origin + pathname` — the query string (where API keys commonly ride: `?api-key=…`, `?api_key=…`) never reaches the client or the logs. The actual request still uses the full URL. |
34
+ | `fetchWithTimeout` | `(url, timeoutMs, context, options?: FetchWithTimeoutOptions) -> Promise<Response>` | Wraps `fetch` with `AbortController` timeout. `timeoutMs` bounds the **whole exchange**: on a 2xx carrying a body the returned `Response` is a passthrough wrapper that keeps the deadline armed until the body closes, errors, or is cancelled, so a stalled stream rejects the caller's `.text()`/`.json()` with the same `Timeout` error the header phase raises. `status`, `statusText`, `headers`, `url`, `redirected`, and `type` carry across the wrapper; the original body is locked by it, and bodyless/null-body responses (HEAD, 204/205/304) come back untouched. `FetchWithTimeoutOptions` extends `RequestInit` (minus `signal`) and adds `rejectPrivateIPs?: boolean`, `expectedStatuses?: number[]` (listed non-2xx statuses logged at `debug` not `error`, still thrown), `errorBodyLimit?: number` (bytes of a non-2xx body kept, default `500`), `errorHeaders?: string[]` (response headers copied onto `error.data.headers` on a non-2xx — same selector as `httpErrorFromResponse` below; `location` is selectable under `redirect: 'manual'` but does **not** compose with `rejectPrivateIPs`, whose per-hop branch consumes the 3xx before the throw path sees it), and `signal?: AbortSignal` (external cancellation — an abort on it throws `RequestCancelled` (-32011), logged at `info` and outside `withRetry`'s transient set, since the caller is gone and no retry can reach them). On a non-2xx, `error.data` carries `status`/`body` plus the legacy `statusCode`/`responseBody` aliases (identical values; consolidating in a future major); a body over `errorBodyLimit` is captured from both ends — 40% head, 60% tail, joined by `…[N bytes elided]…` — so a diagnostic behind a boilerplate preamble survives the cap, while a body still streaming at the 16 KiB scan ceiling stays head-only with a trailing `…`. SSRF guard (best-effort, not hard isolation): blocks RFC 1918, loopback, link-local, CGNAT, cloud metadata. DNS validation on Node, Bun, and Cloudflare Workers under `nodejs_compat`; hostname-only fallback otherwise. **Both resolvers are queried** — `resolve4`/`resolve6` (c-ares) and `lookup` (the system resolver, which is what reads `/etc/hosts`, split DNS, and NSS modules) — and a non-global answer from either rejects. Runtimes differ in which resolver the connection uses (Bun 1.4 moved `net.connect()` on Linux to `getaddrinfo` while leaving `dns.resolve*()` on c-ares), so checking one alone leaves a name the other can see unguarded; each probe settles independently, so a resolver absent from the runtime is skipped rather than fatal. Manual redirect following (max 5) with per-hop SSRF check. **DNS rebinding / TOCTOU gap** — the validation lookup and `fetch`'s own resolution are independent; pair with egress controls or a DNS-pinning fetch proxy for strong isolation. **Error/log redaction:** URLs written into thrown errors and log lines are reduced to `origin + pathname` — the query string (where API keys commonly ride: `?api-key=…`, `?api_key=…`) never reaches the client or the logs. The actual request still uses the full URL. |
35
35
  | `withRetry` | `<T>(fn: (attempt: RetryAttempt) => Promise<T>, options?: RetryOptions) -> Promise<T>` | Executes `fn` with exponential backoff. Retries on transient errors (`ServiceUnavailable`, `Timeout`, `RateLimited`); non-transient errors fail immediately. Honors an upstream `Retry-After` on `data.retryAfter` (delta-seconds or HTTP-date) over exponential backoff, capped at `maxDelayMs`; a requested wait beyond the cap fails fast rather than sleeping. On exhaustion, enriches the final error with attempt count in message and `data.retryAttempts`. **Place the retry boundary around the full pipeline** (fetch + parse), not just the network call. `RetryOptions`: `maxRetries` (default `3`), `baseDelayMs` (default `1000`), `maxDelayMs` (default `30000`), `jitter` (default `0.25`), `operation` (log label), `context` (RequestContext), `signal` (AbortSignal), `isTransient` (custom predicate), `deadlineMs` (total wall-clock budget — see below). |
36
36
  | `RetryAttempt` | `{ readonly signal: AbortSignal; readonly remainingMs: number }` | What `fn` receives each attempt. `signal` is `AbortSignal.any` over the `deadlineMs` clock and `options.signal`; `remainingMs` is what is left of the total budget as the attempt starts, never negative and `Number.POSITIVE_INFINITY` when no deadline is set — so `Math.min(perAttemptMs, remainingMs)` is correct either way. A zero-argument `fn` stays assignable, so existing callers compile unchanged. |
37
37
  | `deadlineMs` | `RetryOptions` field | One wall-clock budget across every attempt, backoff, and honored `Retry-After` — the bound `maxRetries` plus a per-attempt timeout cannot express. Four 30s attempts outlast a client's 60s request timeout, so the caller gets a transport timeout instead of the server's classified error. **Thread `attempt.signal` into the attempt's I/O** (`fetchWithTimeout(url, Math.min(30_000, remainingMs), ctx, { signal })`) or the deadline overshoots by one in-flight request. Clock is `AbortController` + `setTimeout` (never `AbortSignal.timeout()`, per the Bun realm mismatch), cleared on return — no timer outlives the call. Expiry rejects with `Timeout` (-32004) carrying `data: { reason: 'retry_deadline_exceeded', deadlineMs, elapsedMs, retryAttempts }` and the last attempt's error as `cause`; **one shape for every expiry**, including the `RequestCancelled` that an external-signal abort raises inside `fetchWithTimeout` and the raw abort reason a mid-backoff expiry would otherwise surface. No `retryable` flag (a narrower call can still succeed) and no `attempt` index (`retryAttempts` carries it). A backoff that would outlast the remaining budget fails fast with the expiry instead of sleeping into a certain timeout; an honored `Retry-After` that would outlast it takes the `maxDelayMs` exit instead — the attempt's error unchanged, `data.retryAfter` intact, since "wait the window the upstream named" is still the caller's action. **Three clocks stay distinct:** a caller abort on `options.signal` keeps precedence and rethrows unchanged (stamped `RequestCancelled` by the handler factory), a single attempt's timeout is `Timeout` with `errorSource: 'FetchTimeout'` and no `reason`, and the expiry is `Timeout` with the `reason`. Unset, behavior is identical to before — attempt counts, delays, log lines, and the exhausted-error shape untouched. Bounds **one** ladder: a tool making three upstream calls threads its own remaining budget into each. |
38
38
  | `defaultIsTransient` | `(error: unknown) -> boolean` | The predicate `withRetry` uses when `isTransient` is omitted: an `McpError` with a transient code (`ServiceUnavailable`, `Timeout`, `RateLimited`) unless it carries `data.retryable === false` or `data.reason === 'pacer_shed'`; any non-`McpError` throw is assumed transient. Exported so `isTransient` — which **replaces** the default outright — can compose instead of mirroring the transient set, which drifts silently when the framework's classification changes: `isTransient: (error) => !isMyBudgetRefusal(error) && defaultIsTransient(error)`, or the inverse `defaultIsTransient(error) \|\| isMyRetryableShape(error)`. The transient code set itself stays private (a module-level `Set` an exported binding could be mutated into framework-wide retry behavior). |
39
- | `httpErrorFromResponse` | `(response: Response, options?: HttpErrorFromResponseOptions) -> Promise<McpError>` | Maps an HTTP `Response` to a properly classified `McpError` — full status table including 401/403/408/422/429/5xx, body capture (truncated), `retry-after` header, optional `cause`. `error.data` carries `status`/`body` plus the legacy `statusCode`/`responseBody` aliases (identical values), so a consumer can classify either helper's error without knowing which raised it. Use this instead of hand-rolling `if (status === 429) ...` ladders. Reads the response body — `clone()` first if you need it elsewhere. **`error.data` is client-facing** — the framework forwards it verbatim as `structuredContent.error.data` — so the full upstream URL is **omitted by default**: a request URL routinely carries user input, internal identifiers, or an API key in its query string. `includeUrl: true` opts into `data.url` carrying the full `response.url`; with an empty `response.url` no key is added either way, and the message still names the host. `HttpErrorFromResponseOptions`: `service?` (logical name in message, e.g. `'NCBI'`), `captureBody?` (default `true`), `bodyLimit?` (default `500`), `includeUrl?` (default `false`), `data?` (extra fields merged into `error.data`, overriding defaults on key collision — a caller's own `url` still reaches the wire), `cause?`, `codeOverride?` (per-status mapping override). Pairs naturally with `withRetry` — both classify codes the same way. A 501 also carries `data.retryable: false`, so retry fails it fast instead of re-asking for a method the upstream does not implement. |
39
+ | `httpErrorFromResponse` | `(response: Response, options?: HttpErrorFromResponseOptions) -> Promise<McpError>` | Maps an HTTP `Response` to a properly classified `McpError` — full status table including 401/403/408/422/429/5xx, body capture (truncated), `retry-after` header, optional `cause`. `error.data` carries `status`/`body` plus the legacy `statusCode`/`responseBody` aliases (identical values), so a consumer can classify either helper's error without knowing which raised it. Use this instead of hand-rolling `if (status === 429) ...` ladders. Reads the response body — `clone()` first if you need it elsewhere. **`error.data` is client-facing** — the framework forwards it verbatim as `structuredContent.error.data` — so the full upstream URL is **omitted by default**: a request URL routinely carries user input, internal identifiers, or an API key in its query string. `includeUrl: true` opts into `data.url` carrying the full `response.url`; with an empty `response.url` no key is added either way, and the message still names the host. Response headers are opt-in on the same footing: `errorHeaders: ['x-ratelimit-remaining-usd', 'x-request-id']` copies the named headers onto `data.headers` under **lowercase** keys — selection is case-insensitive and entries differing only in case collapse to one key, presence follows `Headers.has()` (an empty value is captured as `''`, an absent header adds no key), and a multi-valued field is captured comma-joined as `Headers.get()` returns it. Omitted, empty, or matching nothing, no `headers` key is emitted. `set-cookie` is **never** captured whatever the selector says: it is credential-bearing and `Headers.get()` joins its values into a string that is not a valid reconstruction. Every selected value reaches the client, so never name a header that carries a credential — and a selected `Location` can itself carry a sensitive path, query, or token. `HttpErrorFromResponseOptions`: `service?` (logical name in message, e.g. `'NCBI'`), `captureBody?` (default `true`), `bodyLimit?` (default `500`), `includeUrl?` (default `false`), `errorHeaders?` (default none), `data?` (extra fields merged into `error.data`, overriding defaults on key collision — a caller's own `url` or `headers` still reaches the wire), `cause?`, `codeOverride?` (per-status mapping override). Pairs naturally with `withRetry` — both classify codes the same way. A 501 also carries `data.retryable: false`, so retry fails it fast instead of re-asking for a method the upstream does not implement. |
40
40
  | `createPacer` | `(options: PacerOptions) -> Pacer` | FIFO queue in front of one rate-limited upstream — the outbound counterpart to `RateLimiter` (`utils/security`), which is inbound, per-caller, and reject-only, so it cannot queue work against an upstream budget. `pacer.run(task, { signal?, maxWaitMs? })` holds `task` until every `limits` window, `minStartGapMs`, `maxConcurrent`, and the cooldown gate allow it, then calls it with the caller's signal. `PacerOptions`: `name` (author-set telemetry label), `limits` (`{ requests, perMs }[]` — each a sliding window over recorded **start** times, so a slow response never widens the rate the upstream sees; all must allow a start), `minStartGapMs` (**not** expressible through `limits`: `{ requests: 10, perMs: 1000 }` permits ten starts in the same millisecond), `maxConcurrent`, `maxQueueDepth` (absolute backpressure for callers passing no `maxWaitMs`; rejects without arming a timer), `cooldown` (`{ baseMs, maxMs }`). **Shed:** `maxWaitMs` bounds queue time only, never the task. The projected wait is exact over the windows and the gap but a lower bound once `maxConcurrent` binds (a slot frees on an unknowable completion), so enqueue rejects only when that lower bound already exceeds `maxWaitMs` — no false sheds — and a still-queued entry rejects when `maxWaitMs` elapses. The shed error is `rateLimited` (-32003) with `data: { reason: 'pacer_shed', retryAfter, queueDepth }` and **no `retryable: false`** — to the calling agent a shed is an ordinary rate limit (wait `retryAfter`, call again) and that flag would say the opposite; `defaultIsTransient` reads the `reason` instead, so an enclosing `withRetry` fails fast rather than sleeping past the deadline the shed enforces. **Cooldown gate:** a `RateLimited` thrown by the task closes the gate for every queued caller until an absolute instant, `min(max(baseMs · 2^(consecutive−1), retryAfter), maxMs)` — `maxMs` caps both the doubling and an honored `Retry-After`, so a pathological upstream value cannot park the queue. Absent or unparseable `retryAfter` leaves the doubling; any other error leaves the gate open; the first success resets the count. **Composition:** `withRetry(({ signal }) => pacer.run(fn, { signal }), { signal, deadlineMs })` — retry outside, pacer inside, so each attempt re-queues and is re-paced. Because the gate is an absolute instant rather than a duration counted from dequeue, retry's `Retry-After` sleep and the gate overlap in wall-clock instead of summing: the window is waited once, not twice. **Lifecycle:** timers and `AbortSignal` only, process-local; the dispatch timer is `unref()`'d where supported; `dispose()` / `[Symbol.dispose]()` clears it and rejects queued waiters with `RequestCancelled` (in-flight tasks are left to finish) — wire it through `createApp({ teardown })`. On Workers state is per-isolate so the limits bind per isolate, OTel is off so the metrics are inert, and `createWorkerHandler` accepts no `teardown`. Metrics: `mcp.pacer.queue_depth`, `mcp.pacer.wait`, `mcp.pacer.sheds`, `mcp.pacer.cooldowns`, attributed by `mcp.pacer.name` only — see `api-telemetry`. |
41
- | `httpStatusToErrorCode` | `(status: number) -> JsonRpcErrorCode \| undefined` | Sync status → code lookup. Returns `undefined` for 1xx/2xx/3xx. Use when you need just the code without a `Response` object handy. No status maps to `InternalError` — that code means *this* server failed, which a remote status cannot establish; every 5xx is `ServiceUnavailable` (or `Timeout` for 504) and so picks up `withRetry`'s default transient policy. |
41
+ | `httpStatusToErrorCode` | `(status: number) -> JsonRpcErrorCode \| undefined` | Sync status → code lookup. Returns `undefined` for 1xx/2xx. A 3xx maps to `InvalidRequest` — it reaches error mapping under `redirect: 'manual'`, where the request as sent cannot be served at this URL, and that code is outside `withRetry`'s transient set since re-issuing returns the same redirect. Use when you need just the code without a `Response` object handy. No status maps to `InternalError` — that code means *this* server failed, which a remote status cannot establish; every 5xx is `ServiceUnavailable` (or `Timeout` for 504) and so picks up `withRetry`'s default transient policy. |
42
42
 
43
43
  ---
44
44
 
@@ -4,7 +4,7 @@ description: >
4
4
  Design the tool surface, resources, and service layer for a new MCP server. Use when starting a new server, planning a major feature expansion, or when the user describes a domain/API they want to expose via MCP. Produces a design doc at docs/design.md that drives implementation.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.27"
7
+ version: "2.28"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -437,9 +437,11 @@ throw new Error('Not found');
437
437
  "No session working directory set. Please specify a 'path' or use 'git_set_working_dir' first."
438
438
 
439
439
  // Good — structured hint in error data using the canonical `data.recovery.hint` shape.
440
- // The framework auto-mirrors `data.recovery.hint` into the content[] text as
440
+ // The framework mirrors `data.recovery.hint` into the content[] text as
441
441
  // `Recovery: <hint>` so format()-only clients (Claude Desktop) see the same
442
442
  // guidance structuredContent clients (Claude Code) read from `error.data.recovery.hint`.
443
+ // A hint the message already contains verbatim is dropped from the text rather
444
+ // than stated twice, and stays on structuredContent either way.
443
445
  throw forbidden(
444
446
  "Cannot perform 'reset --hard' on protected branch 'main' without explicit confirmation.",
445
447
  {
@@ -4,7 +4,7 @@ description: >
4
4
  Exercise tools, resources, and prompts against a live HTTP server via MCP JSON-RPC over curl. Starts the server, surfaces the catalog, runs real and adversarial inputs, measures every call (bytes, token estimate, wall-clock) and weighs the catalog, and produces a tight report with concrete findings and numbered follow-up options. Use after adding or modifying definitions, or when the user asks to test, try out, or verify their MCP surface.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.15"
7
+ version: "2.16"
8
8
  audience: external
9
9
  type: debug
10
10
  ---
@@ -436,7 +436,7 @@ When a call surprises you — slow, hangs, returns terse output, surfaces an unh
436
436
 
437
437
  - **`content[]` is an array of blocks — read all of them, never just `content[0]`.** A success result is assembled as `[...ctx.content media blocks, ...the format()/JSON domain render, ...the enrichment trailer]`. Everything the handler put on `ctx.enrich` — empty-result notices, totals, query echoes, truncation disclosure — renders in that trailer, a **separate trailing block**, not inside the `format()` block. Quoting `content[0].text` and reporting those fields as absent from `content[]` is a false parity gap; the suggested fix (render them in `format()` too) would double-render them. Dump `.result.content` in full before claiming drift.
438
438
  - Tool domain errors return `{result: {content: [...], isError: true}}` — they live in `result`, not `error`. Check `isError`, not the JSON-RPC error field.
439
- - **Tool error code/reason** rides on `result.structuredContent.error.{code, message, data?.reason}` — inspect that, not just the text. `data` is only spread when the handler threw an `McpError` (or `ZodError`); plain `throw new Error(...)` won't populate `data.reason`. Use `ctx.fail`-thrown errors when the contract reason matters. The text in `result.content[0].text` mirrors the message and includes `Recovery: <hint>` when `data.recovery.hint` is present.
439
+ - **Tool error code/reason** rides on `result.structuredContent.error.{code, message, data?.reason}` — inspect that, not just the text. `data` is only spread when the handler threw an `McpError` (or `ZodError`); plain `throw new Error(...)` won't populate `data.reason`. Use `ctx.fail`-thrown errors when the contract reason matters. The text in `result.content[0].text` mirrors the message, adds `Recovery: <hint>` when `data.recovery.hint` says something the message does not already say, and closes with `(reason <reason> · not retryable)` for whichever of `data.reason` / `data.retryable` is present — the numeric code stays JSON-only.
440
440
  - **Resource errors** are JSON-RPC-level — they appear in the top-level `error.{code, data.reason}` field, not inside `result`. Resource handlers re-throw rather than producing an `isError` envelope.
441
441
  - JSON-RPC `error` only appears for protocol issues (bad session, malformed envelope, unknown method).
442
442
  - `mcp_call` already strips SSE framing. Pipe to `jq` for readability.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyanheads/mcp-ts-core",
3
- "version": "0.13.4",
3
+ "version": "0.13.5",
4
4
  "mcpName": "io.github.cyanheads/mcp-ts-core",
5
5
  "description": "Agent-native TypeScript framework for MCP servers. Includes runtime infrastructure and agent skills for building, testing, and shipping servers.",
6
6
  "files": [
@@ -214,7 +214,7 @@ Handlers receive a unified `ctx` object. Key properties:
214
214
 
215
215
  Handlers throw — the framework catches, classifies, and formats.
216
216
 
217
- **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. Pass `ctx.recoveryFor('reason')` as the throw's data to put it on the wire (`data.recovery.hint`, mirrored into `content[]` text); override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
217
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. Pass `ctx.recoveryFor('reason')` as the throw's data to put it on the wire (`data.recovery.hint`, mirrored into `content[]` text unless the message already contains it verbatim); override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
218
218
 
219
219
  ```ts
220
220
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
@@ -214,7 +214,7 @@ Handlers receive a unified `ctx` object. Key properties:
214
214
 
215
215
  Handlers throw — the framework catches, classifies, and formats.
216
216
 
217
- **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. Pass `ctx.recoveryFor('reason')` as the throw's data to put it on the wire (`data.recovery.hint`, mirrored into `content[]` text); override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
217
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. Pass `ctx.recoveryFor('reason')` as the throw's data to put it on the wire (`data.recovery.hint`, mirrored into `content[]` text unless the message already contains it verbatim); override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
218
218
 
219
219
  ```ts
220
220
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';