@cyanheads/mcp-ts-core 0.13.3 → 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 (162) hide show
  1. package/AGENTS.md +9 -7
  2. package/CLAUDE.md +9 -7
  3. package/README.md +1 -1
  4. package/changelog/0.13.x/0.13.4.md +65 -0
  5. package/changelog/0.13.x/0.13.5.md +41 -0
  6. package/changelog/template.md +7 -7
  7. package/dist/config/appRoot.d.ts.map +1 -1
  8. package/dist/config/appRoot.js +48 -16
  9. package/dist/config/appRoot.js.map +1 -1
  10. package/dist/core/app.d.ts +20 -0
  11. package/dist/core/app.d.ts.map +1 -1
  12. package/dist/core/app.js +1 -0
  13. package/dist/core/app.js.map +1 -1
  14. package/dist/core/index.d.ts +1 -0
  15. package/dist/core/index.d.ts.map +1 -1
  16. package/dist/core/index.js.map +1 -1
  17. package/dist/linter/rules/error-contract-rules.d.ts +65 -3
  18. package/dist/linter/rules/error-contract-rules.d.ts.map +1 -1
  19. package/dist/linter/rules/error-contract-rules.js +138 -3
  20. package/dist/linter/rules/error-contract-rules.js.map +1 -1
  21. package/dist/linter/rules/index.d.ts +1 -1
  22. package/dist/linter/rules/index.d.ts.map +1 -1
  23. package/dist/linter/rules/index.js +1 -1
  24. package/dist/linter/rules/index.js.map +1 -1
  25. package/dist/linter/rules/resource-rules.d.ts.map +1 -1
  26. package/dist/linter/rules/resource-rules.js +4 -2
  27. package/dist/linter/rules/resource-rules.js.map +1 -1
  28. package/dist/linter/rules/schema-rules.d.ts +19 -0
  29. package/dist/linter/rules/schema-rules.d.ts.map +1 -1
  30. package/dist/linter/rules/schema-rules.js +36 -0
  31. package/dist/linter/rules/schema-rules.js.map +1 -1
  32. package/dist/linter/rules/tool-rules.d.ts +17 -0
  33. package/dist/linter/rules/tool-rules.d.ts.map +1 -1
  34. package/dist/linter/rules/tool-rules.js +101 -3
  35. package/dist/linter/rules/tool-rules.js.map +1 -1
  36. package/dist/mcp-server/handlerContext.d.ts +11 -2
  37. package/dist/mcp-server/handlerContext.d.ts.map +1 -1
  38. package/dist/mcp-server/handlerContext.js +6 -4
  39. package/dist/mcp-server/handlerContext.js.map +1 -1
  40. package/dist/mcp-server/inputRequired.d.ts +35 -2
  41. package/dist/mcp-server/inputRequired.d.ts.map +1 -1
  42. package/dist/mcp-server/inputRequired.js +116 -2
  43. package/dist/mcp-server/inputRequired.js.map +1 -1
  44. package/dist/mcp-server/resources/resource-registration.d.ts +2 -1
  45. package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
  46. package/dist/mcp-server/resources/resource-registration.js +4 -4
  47. package/dist/mcp-server/resources/resource-registration.js.map +1 -1
  48. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts +2 -1
  49. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
  50. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +5 -2
  51. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
  52. package/dist/mcp-server/server.d.ts.map +1 -1
  53. package/dist/mcp-server/server.js +11 -2
  54. package/dist/mcp-server/server.js.map +1 -1
  55. package/dist/mcp-server/tools/tool-registration.d.ts +2 -1
  56. package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
  57. package/dist/mcp-server/tools/tool-registration.js +4 -4
  58. package/dist/mcp-server/tools/tool-registration.js.map +1 -1
  59. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts +114 -0
  60. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts.map +1 -0
  61. package/dist/mcp-server/tools/utils/inputPrevalidation.js +428 -0
  62. package/dist/mcp-server/tools/utils/inputPrevalidation.js.map +1 -0
  63. package/dist/mcp-server/tools/utils/strictenRecord.d.ts +42 -0
  64. package/dist/mcp-server/tools/utils/strictenRecord.d.ts.map +1 -0
  65. package/dist/mcp-server/tools/utils/strictenRecord.js +48 -0
  66. package/dist/mcp-server/tools/utils/strictenRecord.js.map +1 -0
  67. package/dist/mcp-server/tools/utils/toolDefinition.d.ts +27 -0
  68. package/dist/mcp-server/tools/utils/toolDefinition.d.ts.map +1 -1
  69. package/dist/mcp-server/tools/utils/toolDefinition.js +35 -6
  70. package/dist/mcp-server/tools/utils/toolDefinition.js.map +1 -1
  71. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +33 -5
  72. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  73. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +136 -20
  74. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  75. package/dist/services/canvas/core/CanvasInstance.d.ts +4 -1
  76. package/dist/services/canvas/core/CanvasInstance.d.ts.map +1 -1
  77. package/dist/services/canvas/core/CanvasInstance.js +5 -0
  78. package/dist/services/canvas/core/CanvasInstance.js.map +1 -1
  79. package/dist/services/canvas/core/CanvasRegistry.d.ts +52 -1
  80. package/dist/services/canvas/core/CanvasRegistry.d.ts.map +1 -1
  81. package/dist/services/canvas/core/CanvasRegistry.js +79 -3
  82. package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
  83. package/dist/services/canvas/core/sqlGate.d.ts +14 -1
  84. package/dist/services/canvas/core/sqlGate.d.ts.map +1 -1
  85. package/dist/services/canvas/core/sqlGate.js +69 -7
  86. package/dist/services/canvas/core/sqlGate.js.map +1 -1
  87. package/dist/services/canvas/index.d.ts +2 -2
  88. package/dist/services/canvas/index.d.ts.map +1 -1
  89. package/dist/services/canvas/index.js +2 -2
  90. package/dist/services/canvas/index.js.map +1 -1
  91. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts +20 -0
  92. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts.map +1 -1
  93. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js +75 -25
  94. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js.map +1 -1
  95. package/dist/services/canvas/types.d.ts +6 -1
  96. package/dist/services/canvas/types.d.ts.map +1 -1
  97. package/dist/types-global/errors.d.ts +30 -0
  98. package/dist/types-global/errors.d.ts.map +1 -1
  99. package/dist/types-global/errors.js +4 -3
  100. package/dist/types-global/errors.js.map +1 -1
  101. package/dist/utils/index.d.ts +3 -2
  102. package/dist/utils/index.d.ts.map +1 -1
  103. package/dist/utils/index.js +3 -2
  104. package/dist/utils/index.js.map +1 -1
  105. package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
  106. package/dist/utils/internal/error-handler/errorHandler.js +13 -3
  107. package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
  108. package/dist/utils/internal/error-handler/mappings.d.ts +14 -1
  109. package/dist/utils/internal/error-handler/mappings.d.ts.map +1 -1
  110. package/dist/utils/internal/error-handler/mappings.js +19 -1
  111. package/dist/utils/internal/error-handler/mappings.js.map +1 -1
  112. package/dist/utils/internal/error-handler/types.d.ts +12 -1
  113. package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
  114. package/dist/utils/internal/performance.d.ts.map +1 -1
  115. package/dist/utils/internal/performance.js +4 -1
  116. package/dist/utils/internal/performance.js.map +1 -1
  117. package/dist/utils/network/fetchWithTimeout.d.ts +24 -4
  118. package/dist/utils/network/fetchWithTimeout.d.ts.map +1 -1
  119. package/dist/utils/network/fetchWithTimeout.js +10 -6
  120. package/dist/utils/network/fetchWithTimeout.js.map +1 -1
  121. package/dist/utils/network/httpError.d.ts +56 -6
  122. package/dist/utils/network/httpError.d.ts.map +1 -1
  123. package/dist/utils/network/httpError.js +56 -7
  124. package/dist/utils/network/httpError.js.map +1 -1
  125. package/dist/utils/network/pacer.d.ts +117 -0
  126. package/dist/utils/network/pacer.d.ts.map +1 -0
  127. package/dist/utils/network/pacer.js +304 -0
  128. package/dist/utils/network/pacer.js.map +1 -0
  129. package/dist/utils/network/retry.d.ts +119 -3
  130. package/dist/utils/network/retry.d.ts.map +1 -1
  131. package/dist/utils/network/retry.js +176 -35
  132. package/dist/utils/network/retry.js.map +1 -1
  133. package/dist/utils/security/rateLimiter.d.ts +19 -1
  134. package/dist/utils/security/rateLimiter.d.ts.map +1 -1
  135. package/dist/utils/security/rateLimiter.js +49 -1
  136. package/dist/utils/security/rateLimiter.js.map +1 -1
  137. package/dist/utils/telemetry/attributes.d.ts +27 -0
  138. package/dist/utils/telemetry/attributes.d.ts.map +1 -1
  139. package/dist/utils/telemetry/attributes.js +38 -0
  140. package/dist/utils/telemetry/attributes.js.map +1 -1
  141. package/framework-skills/add-tool/SKILL.md +49 -5
  142. package/framework-skills/api-canvas/SKILL.md +37 -16
  143. package/framework-skills/api-config/SKILL.md +4 -4
  144. package/framework-skills/api-context/SKILL.md +4 -2
  145. package/framework-skills/api-errors/SKILL.md +39 -7
  146. package/framework-skills/api-linter/SKILL.md +92 -5
  147. package/framework-skills/api-telemetry/SKILL.md +43 -4
  148. package/framework-skills/api-utils/SKILL.md +9 -5
  149. package/framework-skills/api-utils/references/security.md +2 -2
  150. package/framework-skills/design-mcp-server/SKILL.md +20 -3
  151. package/framework-skills/field-test/SKILL.md +4 -2
  152. package/framework-skills/git-wrapup/SKILL.md +87 -69
  153. package/framework-skills/release-and-publish/SKILL.md +5 -5
  154. package/framework-skills/release-pr-review/SKILL.md +5 -5
  155. package/framework-skills/report-issue-framework/SKILL.md +6 -35
  156. package/framework-skills/report-issue-local/SKILL.md +6 -36
  157. package/package.json +2 -2
  158. package/templates/.github/ISSUE_TEMPLATE/bug_report.yml +2 -2
  159. package/templates/.github/ISSUE_TEMPLATE/feature_request.yml +2 -2
  160. package/templates/AGENTS.md +1 -1
  161. package/templates/CLAUDE.md +1 -1
  162. package/templates/changelog/template.md +7 -7
@@ -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.12"
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
 
@@ -116,8 +116,33 @@ throw ctx.fail('no_match', `No item ${id}`, {
116
116
  });
117
117
  ```
118
118
 
119
+ > **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.
120
+
119
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.
120
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
+
121
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.
122
147
 
123
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.
@@ -354,21 +379,25 @@ Checked before common patterns. Cover: AWS exception names, HTTP status codes, D
354
379
 
355
380
  ### Error-path parity
356
381
 
357
- 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:
358
383
 
359
384
  | Surface | Content | Read by |
360
385
  |:--------|:--------|:--------|
361
- | `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 |
362
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 |
363
388
 
364
389
  Important properties:
365
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.
366
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.
367
- - **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.
368
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.
369
- - **`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`.
370
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.
371
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.
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.
400
+ - **Some rejections never happen at all.** An ordered pre-validation step wraps the parse: a client-added root key is dropped, a declared or case-style key alias is rewritten to its canonical name, and — only after a failed parse — a JSON-stringified array is repaired and the arguments parsed once more. A call the step rescues succeeds outright and produces no error envelope; a call it cannot rescue throws the rejection above verbatim, same code, message, `data.issues`, and `data.recovery.hint`. See the `add-tool` skill for the boundaries and the per-server switches.
372
401
 
373
402
  **Handler — throw freely, no try/catch:**
374
403
 
@@ -450,12 +479,13 @@ if (!response.ok) {
450
479
 
451
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.
452
481
 
453
- > **Body reaches the client.** `error.data` 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.
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.
454
483
 
455
484
  Full status table:
456
485
 
457
486
  | Status | Code |
458
487
  |:-------|:-----|
488
+ | 3xx | `InvalidRequest` — reachable under `redirect: 'manual'`, and outside `withRetry`'s transient set since re-issuing returns the same redirect |
459
489
  | 400 | `InvalidParams` |
460
490
  | 401 | `Unauthorized` |
461
491
  | 402, 403 | `Forbidden` |
@@ -506,6 +536,7 @@ The linter validates the structure of `errors[]` and (when present) cross-checks
506
536
  | `error-contract-recovery-empty` | error | `recovery` is empty/whitespace-only |
507
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 |
508
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 |
509
540
 
510
541
  ### Conformance rules
511
542
 
@@ -513,6 +544,7 @@ The linter validates the structure of `errors[]` and (when present) cross-checks
513
544
  |:-----|:---------|:--------|
514
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. |
515
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. |
516
548
 
517
549
  ### Baseline codes (auto-allowed)
518
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.14"
7
+ version: "1.16"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -44,16 +44,16 @@ Grouped by family. Jump to any rule ID via its anchor.
44
44
  |:-------|:------|:--------|
45
45
  | Definition | `definition-invalid` | [Definition rules](#definition-rules) |
46
46
  | Format parity | `format-parity`, `format-parity-threw`, `format-parity-walk-failed`, `format-parity-depth-limit` | [Format parity](#format-parity) |
47
- | Schema | `schema-is-object`, `describe-on-fields`, `schema-serializable`, `schema-unsatisfiable`, `header-param-designation` | [Schema rules](#schema-rules) |
47
+ | Schema | `schema-is-object`, `describe-on-fields`, `schema-serializable`, `schema-unsatisfiable`, `header-param-designation`, `schema-root-meta-discarded` | [Schema rules](#schema-rules) |
48
48
  | Portability | `schema-format-portability`, `schema-anyof-needs-type`, `schema-no-discriminator-keyword`, `schema-no-defs`, `schema-root-oneof-portability`, `schema-dialect-tag` | [Portability rules](#portability-rules) |
49
49
  | Names | `name-required`, `name-format`, `name-unique` | [Name rules](#name-rules) |
50
- | Tools | `description-required`, `handler-required`, `auth-type`, `auth-scope-format`, `annotation-type`, `annotation-coherence`, `meta-ui-type`, `meta-ui-resource-uri-required`, `meta-ui-resource-uri-scheme`, `app-tool-resource-pairing`, `canvas-consumer-missing` | [Tool rules](#tool-rules) |
50
+ | Tools | `description-required`, `handler-required`, `auth-type`, `auth-scope-format`, `annotation-type`, `annotation-coherence`, `input-alias-conflict`, `meta-ui-type`, `meta-ui-resource-uri-required`, `meta-ui-resource-uri-scheme`, `app-tool-resource-pairing`, `canvas-consumer-missing` | [Tool rules](#tool-rules) |
51
51
  | Resources | `uri-template-required`, `uri-template-valid`, `resource-name-not-uri`, `template-params-align` | [Resource rules](#resource-rules) |
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
 
@@ -270,6 +270,29 @@ The message names the offending field in the linter's path vocabulary: `input.ro
270
270
 
271
271
  Silent when the schema cannot be converted to JSON Schema at all — that is `schema-serializable`'s diagnostic.
272
272
 
273
+ ### schema-root-meta-discarded
274
+
275
+ **Severity:** warning
276
+
277
+ Fires when a `.describe()` or `.meta()` on a tool's **input root** was discarded by strictening, so the advertised `inputSchema` does not carry it.
278
+
279
+ Zod keys both calls to the schema *instance*, in `z.globalRegistry`. `.strict()` is `catchall(z.never())` — a clone with no link back to the original — so the strictened schema `tool()` stores inherits no entry. Ordering is therefore load-bearing, and nothing in the type signature says so:
280
+
281
+ ```ts
282
+ z.object({ … }).describe('An object root.') // lost — tool() strictens after
283
+ z.object({ … }).strict().describe('An object root.') // kept — already strict, returned untouched
284
+ ```
285
+
286
+ The loss is otherwise invisible in every direction: `describe-on-fields` never asks a root to describe itself, and `schema-anyof-needs-type` reports on the metadata that *survived*, so a dropped `.meta({ anyOf })` reads as no `anyOf` at all — which matters, because `anyOf` with per-branch `type` is the portable way to publish "one of these argument sets is required".
287
+
288
+ **Fix:** move `.strict()` ahead of `.describe()` / `.meta()` on the root. The message names what was discarded and where: `input` for an object or union root, `input|<i>` for a union variant (a union is rebuilt from its strictened options, so the union's own entry and each rebuilt variant's both go).
289
+
290
+ Silent when nothing was strictened — an explicit `.strict()`, `.passthrough()`, or `.catchall(...)` on the root or on every variant — which is exactly the case that advertises the metadata today. Also silent for a definition assembled without the `tool()` builder, since nothing strictened it.
291
+
292
+ Detection happens inside `tool()`, the only place both the authored and the strictened instance exist; by lint time the definition holds the clone, which carries no registry entry and no way back. The record rides a symbol-keyed, non-enumerable property, so `Object.keys(definition)`, `JSON.stringify(definition)`, `tools/list`, `/.well-known/mcp.json`, and `_meta` are all unchanged.
293
+
294
+ Whether the discarded description or metadata should instead reach the wire is a separate question — that changes the advertised bytes, so it is held.
295
+
273
296
  ---
274
297
 
275
298
  ## Portability rules
@@ -432,6 +455,28 @@ Every element in `auth` must be a non-empty string. Empty strings in the array a
432
455
 
433
456
  Catches `readOnlyHint: true` with **any** explicit `destructiveHint` value (even `false`) — the destructive hint is meaningless on a read-only tool, so its presence signals authoring confusion. Drop `destructiveHint` entirely when the tool is read-only.
434
457
 
458
+ ### input-alias-conflict
459
+
460
+ **Severity:** error
461
+
462
+ Fires when a tool's `inputAliases` cannot resolve to exactly one declared input key. An alias is a one-to-one mapping fixed ahead of time — the reason it is accepted where nearest-key matching is not — so an alias resolving to none or to more than one is a definition error, not a runtime one. The runtime declines an ambiguous rewrite silently and the caller sees the ordinary strict rejection, which reads as the alias simply not working.
463
+
464
+ Five conditions, all decidable from the definition:
465
+
466
+ | Condition | Example |
467
+ |:--|:--|
468
+ | An alias must not equal a declared key | `input: z.object({ q, query })` with `inputAliases: { q: 'query' }` — a declared key is never rewritten, so the alias can never fire |
469
+ | An alias's target must be a declared key | `inputAliases: { q: 'searchQuery' }` when the schema declares `query` |
470
+ | Two declared keys must not case-fold to one name | `z.object({ maxResults, max_results })` — no alias can resolve between them |
471
+ | An alias must not case-fold to a declared key other than its target | `inputAliases: { max_results: 'query' }` alongside a declared `maxResults` |
472
+ | Two aliases must not case-fold to one name with different targets | `inputAliases: { 'search-term': 'query', search_term: 'maxResults' }` |
473
+
474
+ Case-folding strips `-` and `_` and lowercases — the same fold the runtime rewrite applies, so the rule and the runtime cannot disagree. On a discriminated-union root, every variant's keys count as declared: a rewrite resolves against the selected variant, so an alias naming a key no variant declares can never fire.
475
+
476
+ **Fix:** point the alias at an existing key, rename the key it shadows, or drop the alias. Also fires when `inputAliases` is not an object of non-empty string targets.
477
+
478
+ Silent when no `inputAliases` is declared — the case-style half needs no declaration and declines ambiguity on its own.
479
+
435
480
  ### meta-ui-type
436
481
 
437
482
  **Severity:** error (MCP Apps tools only)
@@ -770,6 +815,23 @@ Fires when an entry's `when` field is missing or empty. `when` is the human-read
770
815
 
771
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.
772
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
+
773
835
  ### error-contract-recovery-required
774
836
 
775
837
  **Severity:** error
@@ -821,6 +883,31 @@ throw ctx.fail('no_match', 'No items match');
821
883
 
822
884
  The diagnostic message includes the declared reason(s) for the code so you can copy-paste.
823
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
+
824
911
  ---
825
912
 
826
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.9"
7
+ version: "1.12"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -123,10 +123,13 @@ 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) |
130
+ | `mcp.input.ignored_key` | counter | `{keys}` | `mcp.tool.name`, `mcp.input.ignore_rule` (the ignore-list entry that matched, or `underscore_prefix`) |
131
+ | `mcp.input.aliased` | counter | `{keys}` | `mcp.tool.name`, `mcp.input.target` (the declared key), `mcp.input.alias_kind` (`declared`/`case_style`) |
132
+ | `mcp.input.coerced` | counter | `{calls}` | `mcp.tool.name`, `mcp.input.coercion` (`stringified_array`) |
130
133
  | `mcp.resource.reads` | counter | `{reads}` | `mcp.resource.name`, `mcp.resource.success` |
131
134
  | `mcp.resource.duration` | histogram | `ms` | `mcp.resource.name`, `mcp.resource.success` |
132
135
  | `mcp.resource.errors` | counter | `{errors}` | `mcp.resource.name` |
@@ -139,6 +142,27 @@ All custom metrics are namespaced `mcp.*` (or `process.*` / `http.client.*` wher
139
142
  | `mcp.prompt.message_count` | histogram | `{messages}` | `mcp.prompt.name` |
140
143
  | `mcp.requests.active` | up/down counter | `{requests}` | — (in-flight handler executions, all three types) |
141
144
 
145
+ The three `mcp.input.*` counters are the only trace of the pre-validation step a tool call leaves. Each marks a call the strict `input` schema would otherwise have rejected: a client-added root key dropped, a key rewritten to its canonical spelling, or a stringified array repaired after the parse failed (one increment per repaired call, not per repaired value). Nothing about any of them reaches the response, so a client artifact spreading across a fleet shows up here first. All three are lazy: a server whose callers never trip a stage emits no series at all.
146
+
147
+ **Every label is author- or framework-defined — the caller's own key text is never one.** `mcp.input.ignore_rule` is the ignore-list entry that matched or the fixed `underscore_prefix`, bounded by the list's length plus one. `mcp.input.aliased` is labelled by the canonical `mcp.input.target` (a declared property of the tool) and `mcp.input.alias_kind`, not by the alias the caller sent — the case-style half accepts every `-`/`_`/case permutation of a declared key, so labelling the alias would put a caller-controlled set on a permanent series. That is the unbounded-label leak removed from the rate-limiter counter in 0.9.0: a metric attribute set lives until process restart, so anything the caller names belongs on a span or in a log, never on a counter.
148
+
149
+ **To find the raw key, read the debug log**, which carries `ignoredKey` / `alias` alongside the bounded rule and target. The counter tells you a client artifact exists and how often; the log tells you what it is called, which is what you need before extending `input.ignoreKeys`, declaring an `inputAliases` entry, or renaming a parameter.
150
+
151
+ ### Outbound pacer
152
+
153
+ `createPacer` (`/utils`) emits four instruments, all lazy — a server that never queues against an upstream emits no series at all.
154
+
155
+ | Metric | Type | Unit | Attributes |
156
+ |:-------|:-----|:-----|:-----------|
157
+ | `mcp.pacer.queue_depth` | up/down counter | `{requests}` | `mcp.pacer.name` |
158
+ | `mcp.pacer.wait` | histogram | `ms` | `mcp.pacer.name` (enqueue → dispatch, not task duration) |
159
+ | `mcp.pacer.sheds` | counter | `{requests}` | `mcp.pacer.name` (rejected before dispatch — wait budget or queue depth) |
160
+ | `mcp.pacer.cooldowns` | counter | `{cooldowns}` | `mcp.pacer.name` (gate closed by an upstream rate limit) |
161
+
162
+ `mcp.pacer.name` is the **only** attribute on all four — `createPacer({ name })`, set by the server author and bounded by its own configuration. Nothing a caller supplies reaches these series, for the reason above; which upstream call was shed belongs on a span or in a log.
163
+
164
+ Read together: `queue_depth` rising while `wait` climbs means the configured rate is below demand; `sheds` rising against a flat `queue_depth` means callers' `maxWaitMs` budgets are tighter than the window; `cooldowns` rising at all means the upstream is answering 429, so the configured `limits` sit above what it actually grants.
165
+
142
166
  ### Storage, LLM, speech, graph
143
167
 
144
168
  | Metric | Type | Unit | Attributes |
@@ -168,12 +192,27 @@ All custom metrics are namespaced `mcp.*` (or `process.*` / `http.client.*` wher
168
192
  | `mcp.sessions.active` | observable gauge | `{sessions}` | — |
169
193
  | `mcp.heartbeat.failures` | counter | `{failures}` | `mcp.connection.transport` (`stdio`/`http`) |
170
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
+
171
210
  ### Errors, rate limits, HTTP client
172
211
 
173
212
  | Metric | Type | Unit | Attributes |
174
213
  |:-------|:-----|:-----|:-----------|
175
- | `mcp.errors.classified` | counter | `{errors}` | `mcp.error.classified_code` (JSON-RPC code), `operation` |
176
- | `mcp.ratelimit.rejections` | counter | `{rejections}` | `mcp.rate_limit.key` |
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 |
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) |
177
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) |
178
217
 
179
218
  ### Process
@@ -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.9"
7
+ version: "2.11"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -31,10 +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. |
35
- | `withRetry` | `<T>(fn: () => 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). |
36
- | `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. `HttpErrorFromResponseOptions`: `service?` (logical name in message, e.g. `'NCBI'`), `captureBody?` (default `true`), `bodyLimit?` (default `500`), `data?` (extra fields merged into `error.data`), `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. |
37
- | `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. |
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
+ | `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
+ | `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
+ | `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
+ | `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. 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
+ | `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. 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. |
38
42
 
39
43
  ---
40
44
 
@@ -109,7 +109,7 @@ In-process sliding window rate limiter with LRU eviction and OTEL span annotatio
109
109
  new RateLimiter(config: AppConfig, logger: Logger)
110
110
  ```
111
111
 
112
- Reads initial settings from `AppConfig`. Call `configure()` to override at runtime.
112
+ Reads initial settings from `AppConfig`. Call `configure()` to override at runtime — including `maxTrackedKeys`, which is a memory ceiling and is enforced during that call rather than after subsequent key churn.
113
113
 
114
114
  ### Configuration
115
115
 
@@ -131,7 +131,7 @@ Defaults: `windowMs` 15 min, `maxRequests` 100, `cleanupInterval` 5 min, `maxTra
131
131
 
132
132
  | Method | Signature | Notes |
133
133
  |:-------|:----------|:------|
134
- | `configure` | `(config: Partial<RateLimitConfig>) -> void` | Merges partial config; restarts cleanup timer if `cleanupInterval` changed |
134
+ | `configure` | `(config: Partial<RateLimitConfig>) -> void` | Merges partial config; restarts cleanup timer if `cleanupInterval` changed. Lowering `maxTrackedKeys` below the current tracked-key count trims the map to the new cap synchronously, before the call returns — one pass, expired windows dropped before live entries, live entries taken in least-recently-used order, survivors keeping their counts and reset times. Raising it, or restating a value at or above the current size, trims nothing. |
135
135
  | `check` | `(key, context?) -> void` | Throws `McpError(RateLimited)` with data `{ waitTimeSeconds, key, limit, windowMs }` when exceeded; annotates active OTEL span |
136
136
  | `getStatus` | `(key) -> { current, limit, remaining, resetTime } \| null` | Does NOT apply `keyGenerator` — pass the already-resolved key |
137
137
  | `getConfig` | `() -> RateLimitConfig` | Shallow copy of effective config |
@@ -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.26"
7
+ version: "2.28"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -307,6 +307,20 @@ nctIds: z.union([z.string(), z.array(z.string()).max(5)])
307
307
  .describe('A single NCT ID (e.g., "NCT12345678") or an array of up to 5 NCT IDs to fetch.'),
308
308
  ```
309
309
 
310
+ **Input-edge normalization.** For every identifier, code, or enum-ish input, enumerate at design time the variants a caller will plausibly send, and decide per variant: normalize, or error. The rule is **normalize what is certain, error on what is ambiguous.** A variant is certain when the mapping is unambiguous, one-to-one, and preserves the submitted meaning exactly — then the call succeeds instead of returning a miss for a value the server could resolve. Everything short of that is an error naming the expected shape, with a `recovery` routing to the reference tool. Never fuzzy-match a value, broaden a query, swap one entity for another, or drop a filter to make a call succeed: plausible-looking rows from a guessed input are worse than a rejection the agent can act on.
311
+
312
+ | Class | Example | Handling |
313
+ |:---|:---|:---|
314
+ | Case or bare-leaf shorthand of a code | `ACS5`, `acs5` → `acs/acs5` | Normalize before lookup |
315
+ | Domain value alias | `mph` → `m/h`, `kph` → `km/h` | Alias table at the input edge |
316
+ | Composite identifier completed by context | `part: "52"` + `section: "21"` → `52.21` | Try as-given first, retry the composed form on a miss — never rewrite unconditionally, since the bare form can be legitimate |
317
+ | Delimiter-joined list where an array is accepted | `"US,JP,KR"` → `["US","JP","KR"]` | Split on the documented separator |
318
+ | Spelled-out vs. abbreviated name | `"Houston, Texas"` → `"Houston, TX"` | Normalize against the bundled name table |
319
+
320
+ These are **value**-level, and the mappings are domain knowledge — settle them per input in the design doc's param table. Argument **key** names are not: the framework drops client-added root keys and rewrites declared and case-style key aliases before the schema sees the arguments, and repairs a JSON-stringified array against the tool's own schema after a failed parse. Don't re-implement any of that per server — see `add-tool` § *Three things the framework fixes before the schema sees the arguments*.
321
+
322
+ This resolves one submitted value to one canonical value, and does not loosen the strict token match in [MCP-side list filtering](#mcp-side-list-filtering), which scores a query against many candidate names.
323
+
310
324
  #### Output design
311
325
 
312
326
  The output schema and `format` function control what the LLM reads back. Design for the agent's *next decision*, not for a UI or an API consumer. See the `add-tool` skill's **Tool Response Design** section for implementation-level patterns (partial success, empty results, metadata, context budget).
@@ -407,7 +421,7 @@ Errors are part of the tool's interface — design them during the design phase,
407
421
  | **Auth/permissions** | Insufficient scopes, expired token | `Forbidden` / `Unauthorized` | Maybe — escalate or re-auth |
408
422
  | **Server internal** | Parse failure, missing config, unexpected state | `InternalError` | No — server-side issue |
409
423
 
410
- (`InvalidParams` also exists — the SDK emits it when input fails Zod schema validation before the handler runs. Anything the handler itself throws about inputs uses `ValidationError`.)
424
+ (`InvalidParams` also exists — the framework's `parseToolArguments` emits it when input fails Zod schema validation before the handler runs. Anything the handler itself throws about inputs uses `ValidationError`.)
411
425
 
412
426
  The framework auto-classifies many of these at runtime (HTTP status codes, JS error types, common patterns), but explicit classification in the handler gives better error messages. For declared contract failures, throw via `ctx.fail('reason', …)`. For ad-hoc throws outside the contract, use error factories (`notFound()`, `validationError()`, etc.) when the code matters; plain `throw new Error()` when the framework's auto-classification is good enough.
413
427
 
@@ -423,9 +437,11 @@ throw new Error('Not found');
423
437
  "No session working directory set. Please specify a 'path' or use 'git_set_working_dir' first."
424
438
 
425
439
  // Good — structured hint in error data using the canonical `data.recovery.hint` shape.
426
- // The framework auto-mirrors `data.recovery.hint` into the content[] text as
440
+ // The framework mirrors `data.recovery.hint` into the content[] text as
427
441
  // `Recovery: <hint>` so format()-only clients (Claude Desktop) see the same
428
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.
429
445
  throw forbidden(
430
446
  "Cannot perform 'reset --hard' on protected branch 'main' without explicit confirmation.",
431
447
  {
@@ -647,6 +663,7 @@ Items without an `If …:` prefix apply to every design. Conditional items only
647
663
  - [ ] Tool descriptions are imperative present tense, concrete, and include operational guidance where non-obvious
648
664
  - [ ] Parameter `.describe()` text explains what the value is, what it affects, and tradeoffs
649
665
  - [ ] Input schemas use constrained types (enums, literals, regex) over free strings
666
+ - [ ] **If an input is an identifier, code, or enum-ish value:** the variants callers will plausibly send are enumerated per input — the unambiguous, one-to-one, meaning-preserving ones normalized before lookup; the rest rejected with a `recovery` naming the expected shape; any composite form tried as-given before a composed retry
650
667
  - [ ] Output schemas designed for LLM's next action — chaining IDs, post-write state, filtering communicated
651
668
  - [ ] `format()` renders all data the LLM needs — different clients forward different surfaces (Claude Code → `structuredContent`, Claude Desktop → `content[]`); both must carry the same data, not just a count or title
652
669
  - [ ] Error messages guide recovery — name what went wrong and the next tool call (no dead ends)
@@ -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.14"
7
+ version: "2.16"
8
8
  audience: external
9
9
  type: debug
10
10
  ---
@@ -402,6 +402,7 @@ Treat any hit as a `ux` finding in the report. The authoring rule lives under *T
402
402
  |:------------------------------------------------|:-------------|
403
403
  | `include` / `fields` / `expand` / `view` / `projection` parameter | Field selection: non-default value renders requested fields |
404
404
  | Array return with `query` / `filter` inputs | Empty result: does response explain *why* (echo criteria, suggest broadening)? |
405
+ | Identifier, code, or enum-ish input (an ID format, a classification code, a unit, a place name, a list the docs say may be comma-joined) | Value-variant tolerance: re-send the happy-path call with each obvious variant of that value — lowercase, the bare leaf of a hierarchical code, a common domain alias, a delimiter-joined list where an array is accepted, the spelled-out form of an abbreviated name. Pass is either outcome: the call succeeds, or it fails with an error naming the expected shape. A miss or a bare validation failure on a variant that maps one-to-one onto a valid value is a `ux` finding. Probe **values** — variants of the argument *key* name, and a JSON-stringified array as a value, are handled by the framework, not the server. |
405
406
  | Batch / bulk input (arrays of IDs, multi-item ops) | Partial success: mix valid + invalid items |
406
407
  | `annotations.readOnlyHint: true` | Confirm no mutation happened |
407
408
  | `annotations.idempotentHint: true` | Call twice with same input — safe? |
@@ -435,7 +436,7 @@ When a call surprises you — slow, hangs, returns terse output, surfaces an unh
435
436
 
436
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.
437
438
  - Tool domain errors return `{result: {content: [...], isError: true}}` — they live in `result`, not `error`. Check `isError`, not the JSON-RPC error field.
438
- - **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.
439
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.
440
441
  - JSON-RPC `error` only appears for protocol issues (bad session, malformed envelope, unknown method).
441
442
  - `mcp_call` already strips SSE framing. Pipe to `jq` for readability.
@@ -507,6 +508,7 @@ End with:
507
508
  - [ ] Every call's `⏱` line read; any happy-path response over 24,000 B with no disclosure + retrieval path filed as `ux`
508
509
  - [ ] Universal battery run on every definition (happy path, parity against the full `content[]` array, input error)
509
510
  - [ ] Situational categories applied only when triggered
511
+ - [ ] **If a tool takes an identifier, code, or enum-ish input:** that value probed with its obvious variants (lowercase, bare leaf, common alias, delimiter-joined list, spelled-out name); each either succeeded or failed with an error naming the expected shape
510
512
  - [ ] **If >15 tools:** sampled 30–40% for situational testing; skipped definitions listed in report
511
513
  - [ ] **If a tool declared an `errors: [...]` contract:** ≥1 declared failure mode triggered; `result.structuredContent.error.code` and `data.reason` verified against the contract entry
512
514
  - [ ] **If a resource declared an `errors: [...]` contract:** ≥1 declared failure mode triggered; top-level JSON-RPC `error.code` and `error.data.reason` verified against the contract entry