@cyanheads/mcp-ts-core 0.13.8 → 0.13.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/AGENTS.md +51 -24
  2. package/CLAUDE.md +51 -24
  3. package/README.md +10 -10
  4. package/changelog/0.13.x/0.13.10.md +118 -0
  5. package/changelog/0.13.x/0.13.9.md +113 -0
  6. package/dist/config/index.d.ts +3 -0
  7. package/dist/config/index.d.ts.map +1 -1
  8. package/dist/config/index.js +31 -9
  9. package/dist/config/index.js.map +1 -1
  10. package/dist/core/app.d.ts +6 -3
  11. package/dist/core/app.d.ts.map +1 -1
  12. package/dist/core/app.js +21 -5
  13. package/dist/core/app.js.map +1 -1
  14. package/dist/core/context.d.ts +114 -21
  15. package/dist/core/context.d.ts.map +1 -1
  16. package/dist/core/context.js +40 -0
  17. package/dist/core/context.js.map +1 -1
  18. package/dist/core/index.d.ts +1 -1
  19. package/dist/core/index.d.ts.map +1 -1
  20. package/dist/core/index.js.map +1 -1
  21. package/dist/core/serverManifest.d.ts +6 -0
  22. package/dist/core/serverManifest.d.ts.map +1 -1
  23. package/dist/core/serverManifest.js +6 -0
  24. package/dist/core/serverManifest.js.map +1 -1
  25. package/dist/core/worker.d.ts +6 -0
  26. package/dist/core/worker.d.ts.map +1 -1
  27. package/dist/core/worker.js +1 -0
  28. package/dist/core/worker.js.map +1 -1
  29. package/dist/linter/rules/error-contract-rules.d.ts +3 -44
  30. package/dist/linter/rules/error-contract-rules.d.ts.map +1 -1
  31. package/dist/linter/rules/error-contract-rules.js +8 -144
  32. package/dist/linter/rules/error-contract-rules.js.map +1 -1
  33. package/dist/linter/rules/index.d.ts +1 -1
  34. package/dist/linter/rules/index.d.ts.map +1 -1
  35. package/dist/linter/rules/index.js +1 -1
  36. package/dist/linter/rules/index.js.map +1 -1
  37. package/dist/linter/rules/resource-rules.d.ts.map +1 -1
  38. package/dist/linter/rules/resource-rules.js +1 -2
  39. package/dist/linter/rules/resource-rules.js.map +1 -1
  40. package/dist/linter/rules/tool-rules.d.ts +2 -1
  41. package/dist/linter/rules/tool-rules.d.ts.map +1 -1
  42. package/dist/linter/rules/tool-rules.js +37 -3
  43. package/dist/linter/rules/tool-rules.js.map +1 -1
  44. package/dist/mcp-server/handlerContext.d.ts +26 -13
  45. package/dist/mcp-server/handlerContext.d.ts.map +1 -1
  46. package/dist/mcp-server/handlerContext.js +32 -17
  47. package/dist/mcp-server/handlerContext.js.map +1 -1
  48. package/dist/mcp-server/inputRequired.d.ts +133 -12
  49. package/dist/mcp-server/inputRequired.d.ts.map +1 -1
  50. package/dist/mcp-server/inputRequired.js +192 -20
  51. package/dist/mcp-server/inputRequired.js.map +1 -1
  52. package/dist/mcp-server/prompts/prompt-registration.d.ts +10 -2
  53. package/dist/mcp-server/prompts/prompt-registration.d.ts.map +1 -1
  54. package/dist/mcp-server/prompts/prompt-registration.js +49 -11
  55. package/dist/mcp-server/prompts/prompt-registration.js.map +1 -1
  56. package/dist/mcp-server/resources/resource-registration.d.ts +4 -2
  57. package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
  58. package/dist/mcp-server/resources/resource-registration.js +6 -4
  59. package/dist/mcp-server/resources/resource-registration.js.map +1 -1
  60. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts +4 -3
  61. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
  62. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +42 -14
  63. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
  64. package/dist/mcp-server/server.d.ts +9 -0
  65. package/dist/mcp-server/server.d.ts.map +1 -1
  66. package/dist/mcp-server/server.js +14 -13
  67. package/dist/mcp-server/server.js.map +1 -1
  68. package/dist/mcp-server/tools/tool-registration.d.ts +7 -3
  69. package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
  70. package/dist/mcp-server/tools/tool-registration.js +9 -5
  71. package/dist/mcp-server/tools/tool-registration.js.map +1 -1
  72. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts +163 -40
  73. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts.map +1 -1
  74. package/dist/mcp-server/tools/utils/inputPrevalidation.js +330 -114
  75. package/dist/mcp-server/tools/utils/inputPrevalidation.js.map +1 -1
  76. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +40 -19
  77. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  78. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +387 -130
  79. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  80. package/dist/mcp-server/transports/http/httpErrorHandler.d.ts.map +1 -1
  81. package/dist/mcp-server/transports/http/httpErrorHandler.js +7 -1
  82. package/dist/mcp-server/transports/http/httpErrorHandler.js.map +1 -1
  83. package/dist/mcp-server/transports/http/httpTransport.d.ts.map +1 -1
  84. package/dist/mcp-server/transports/http/httpTransport.js +65 -9
  85. package/dist/mcp-server/transports/http/httpTransport.js.map +1 -1
  86. package/dist/mcp-server/transports/stdio/stdioTransport.d.ts +9 -5
  87. package/dist/mcp-server/transports/stdio/stdioTransport.d.ts.map +1 -1
  88. package/dist/mcp-server/transports/stdio/stdioTransport.js +9 -5
  89. package/dist/mcp-server/transports/stdio/stdioTransport.js.map +1 -1
  90. package/dist/services/canvas/core/CanvasRegistry.d.ts +6 -2
  91. package/dist/services/canvas/core/CanvasRegistry.d.ts.map +1 -1
  92. package/dist/services/canvas/core/CanvasRegistry.js +7 -3
  93. package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
  94. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts +82 -18
  95. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts.map +1 -1
  96. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js +620 -328
  97. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js.map +1 -1
  98. package/dist/services/canvas/providers/duckdb/exportWriter.d.ts +11 -7
  99. package/dist/services/canvas/providers/duckdb/exportWriter.d.ts.map +1 -1
  100. package/dist/services/canvas/providers/duckdb/exportWriter.js +19 -16
  101. package/dist/services/canvas/providers/duckdb/exportWriter.js.map +1 -1
  102. package/dist/services/mirror/core/defineMirror.d.ts +1 -0
  103. package/dist/services/mirror/core/defineMirror.d.ts.map +1 -1
  104. package/dist/services/mirror/core/defineMirror.js +1 -0
  105. package/dist/services/mirror/core/defineMirror.js.map +1 -1
  106. package/dist/testing/index.d.ts +17 -2
  107. package/dist/testing/index.d.ts.map +1 -1
  108. package/dist/testing/index.js +21 -7
  109. package/dist/testing/index.js.map +1 -1
  110. package/dist/types-global/errors.d.ts +18 -15
  111. package/dist/types-global/errors.d.ts.map +1 -1
  112. package/dist/utils/index.d.ts +1 -1
  113. package/dist/utils/index.d.ts.map +1 -1
  114. package/dist/utils/index.js.map +1 -1
  115. package/dist/utils/internal/error-handler/errorHandler.d.ts +5 -4
  116. package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
  117. package/dist/utils/internal/error-handler/errorHandler.js +9 -7
  118. package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
  119. package/dist/utils/internal/error-handler/types.d.ts +3 -1
  120. package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
  121. package/dist/utils/internal/performance.d.ts +4 -2
  122. package/dist/utils/internal/performance.d.ts.map +1 -1
  123. package/dist/utils/internal/performance.js +8 -6
  124. package/dist/utils/internal/performance.js.map +1 -1
  125. package/dist/utils/internal/telemetryMessages.d.ts +0 -1
  126. package/dist/utils/internal/telemetryMessages.d.ts.map +1 -1
  127. package/dist/utils/internal/telemetryMessages.js +0 -1
  128. package/dist/utils/internal/telemetryMessages.js.map +1 -1
  129. package/dist/utils/network/pacer.d.ts +38 -5
  130. package/dist/utils/network/pacer.d.ts.map +1 -1
  131. package/dist/utils/network/pacer.js +87 -25
  132. package/dist/utils/network/pacer.js.map +1 -1
  133. package/dist/utils/telemetry/attributes.d.ts +10 -5
  134. package/dist/utils/telemetry/attributes.d.ts.map +1 -1
  135. package/dist/utils/telemetry/attributes.js +10 -5
  136. package/dist/utils/telemetry/attributes.js.map +1 -1
  137. package/framework-skills/add-app-tool/SKILL.md +3 -3
  138. package/framework-skills/add-export/SKILL.md +5 -16
  139. package/framework-skills/add-prompt/SKILL.md +7 -3
  140. package/framework-skills/add-resource/SKILL.md +7 -5
  141. package/framework-skills/add-service/SKILL.md +3 -12
  142. package/framework-skills/add-test/SKILL.md +6 -3
  143. package/framework-skills/add-tool/SKILL.md +40 -42
  144. package/framework-skills/api-auth/SKILL.md +2 -2
  145. package/framework-skills/api-canvas/SKILL.md +17 -8
  146. package/framework-skills/api-config/SKILL.md +5 -4
  147. package/framework-skills/api-context/SKILL.md +168 -42
  148. package/framework-skills/api-errors/SKILL.md +48 -51
  149. package/framework-skills/api-linter/SKILL.md +30 -35
  150. package/framework-skills/api-mirror/SKILL.md +2 -1
  151. package/framework-skills/api-telemetry/SKILL.md +14 -10
  152. package/framework-skills/api-testing/SKILL.md +43 -11
  153. package/framework-skills/api-utils/SKILL.md +2 -2
  154. package/framework-skills/api-workers/SKILL.md +3 -1
  155. package/framework-skills/design-mcp-server/SKILL.md +6 -6
  156. package/framework-skills/field-test/SKILL.md +5 -5
  157. package/framework-skills/git-wrapup/SKILL.md +8 -6
  158. package/framework-skills/orchestrations/SKILL.md +7 -6
  159. package/framework-skills/orchestrations/workflows/field-test-fix.md +9 -19
  160. package/framework-skills/orchestrations/workflows/fix-wrapup-release.md +7 -7
  161. package/framework-skills/orchestrations/workflows/greenfield-build.md +8 -5
  162. package/framework-skills/orchestrations/workflows/maintenance-release.md +8 -8
  163. package/framework-skills/polish-docs-meta/SKILL.md +4 -4
  164. package/framework-skills/release-and-publish/SKILL.md +8 -6
  165. package/framework-skills/release-pr-review/SKILL.md +38 -24
  166. package/framework-skills/report-issue-framework/SKILL.md +7 -5
  167. package/framework-skills/report-issue-local/SKILL.md +8 -6
  168. package/framework-skills/security-pass/SKILL.md +14 -13
  169. package/package.json +6 -5
  170. package/scripts/devcheck.ts +7 -6
  171. package/scripts/install-otel.ts +84 -0
  172. package/scripts/lint-mcp.ts +87 -27
  173. package/scripts/lint-packaging.ts +226 -4
  174. package/scripts/release-github.ts +117 -5
  175. package/templates/.env.example +2 -0
  176. package/templates/AGENTS.md +5 -4
  177. package/templates/CLAUDE.md +5 -4
  178. package/templates/Dockerfile +67 -50
  179. package/templates/_.mcpbignore +2 -0
  180. package/templates/src/mcp-server/tools/definitions/echo.tool.ts +3 -8
@@ -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.17"
7
+ version: "1.19"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -64,16 +64,11 @@ export const fetchTool = tool('fetch_articles', {
64
64
  |:--------|:---------|
65
65
  | Compile time | `ctx.fail('typo')` is a TS error. Auto-completes declared reasons. |
66
66
  | Runtime | `ctx.fail(reason, msg?, data?, options?)` builds an `McpError(contract.code, msg, { ...data, reason }, options)` — `data.reason` is auto-populated from the contract and cannot be overridden by caller-supplied data (spread first, then `reason` written last), so observers see a stable identifier. `options` accepts `{ cause }` for ES2022 error chaining. |
67
+ | Runtime (recovery) | A failure whose `data.reason` names a declared entry and carries no `data.recovery` gets `data.recovery.hint` set to the entry's `recovery` at the handler boundary — see below. |
67
68
  | Lint (devcheck) | Each `code` validated against `JsonRpcErrorCode`. Reasons validated as snake_case + unique within contract. `recovery` validated as non-empty and ≥ 5 words. Build-time only — not invoked at server startup. |
68
- | Lint (conformance) | If the handler `throw new McpError(JsonRpcErrorCode.X)` outside `ctx.fail`, conformance check warns when X isn't declared. The inverse is checked too: a declared reason no `ctx.fail` in the handler names warns as `error-contract-unthrown` (mark it `thrownBy: 'service'` when the service layer produces it), and a `ctx.fail` site that never forwards the declared `recovery` warns as `error-contract-recovery-unforwarded`. |
69
+ | Lint (conformance) | If the handler `throw new McpError(JsonRpcErrorCode.X)` outside `ctx.fail`, conformance check warns when X isn't declared. The inverse is checked too: a declared reason no `ctx.fail` in the handler names warns as `error-contract-unthrown` (mark it `thrownBy: 'service'` when the service layer produces it). |
69
70
 
70
- > **`recovery` is 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
-
72
- #### `ctx.recoveryFor` — opt-in contract resolution
73
-
74
- `ctx.recoveryFor(reason)` returns `{ recovery: { hint: <contract.recovery> } }` for a declared reason, ready to spread into `data`. Always available on `Context` (returns `{}` when no contract is attached or the reason is unknown — spread-safe with no optional chaining). On `HandlerContext<R>` it tightens to a typed signature constrained to the declared reason union.
75
-
76
- Spreading it into the data object and passing it as the data argument are the same call — `ctx.fail` spreads whatever `data` it receives. Spread when the site carries other keys, pass it directly when it carries nothing else. **Forwarding is lint-enforced per throw site:** a `ctx.fail` site that carries neither the resolver nor its own `recovery` key warns as `error-contract-recovery-unforwarded`, because the declared hint then reaches neither client surface and an error-path test asserting `code` and `reason` still passes.
71
+ > **`recovery` is the wire default for its reason.** The contract `recovery` is required metadata documenting the agent's next move when this failure mode fires (a forcing function for thoughtful guidance — placeholders like "Try again." get flagged by the linter), and it is what the caller receives. When a failure whose `data.reason` names a declared entry reaches the tool or resource handler factory with no `data.recovery`, the factory sets `data.recovery.hint` to that entry's `recovery` before it logs the failure and builds the envelope, so the `Error in tool:<name>` record, `structuredContent.error.data`, and the `Recovery:` line in `content[]` carry the same hint. It matches on the reason alone — a bare `ctx.fail('reason')`, a service throwing `notFound(msg, { reason })`, and a declared reason raised through a factory with a different code all get it. A throw-site `recovery` always wins, whatever its shape. An undeclared reason, a tool without `errors[]`, a non-`McpError` throw, and a cancelled call get nothing, and the framework-owned `invalid_arguments` / `client_capability_missing` refusals keep their own hints. The thrown `McpError` is never changed — a handler-level test of `ctx.fail` sees exactly what the throw site wrote — and `runToolContract` applies the same fill, so a contract test sees the production envelope. Prompts declare no contract.
77
72
 
78
73
  ```ts
79
74
  export const calculateTool = tool('calculate', {
@@ -85,32 +80,29 @@ export const calculateTool = tool('calculate', {
85
80
  ],
86
81
  handler(input, ctx) {
87
82
  if (!input.expression.trim()) {
88
- // Static recovery — resolve from the contract.
89
- throw ctx.fail('empty_expression', undefined, { ...ctx.recoveryFor('empty_expression') });
83
+ // Static recovery — the framework fills the contract's hint onto the wire.
84
+ throw ctx.fail('empty_expression');
90
85
  }
91
86
  // ...
92
87
  },
93
88
  });
94
89
  ```
95
90
 
96
- Same pattern works inside services that accept `ctx`:
91
+ Same for a service, which needs no `ctx` to get the hint — the reason is enough:
97
92
 
98
93
  ```ts
99
94
  export class MathService {
100
- parse(expr: string, ctx: Context) {
95
+ parse(expr: string) {
101
96
  try {
102
97
  return mathjs.parse(expr);
103
98
  } catch (err) {
104
- throw validationError(`Parse failed: ${err.message}`, {
105
- reason: 'parse_failed',
106
- ...ctx.recoveryFor('parse_failed'), // {} if calling tool has no matching reason
107
- });
99
+ throw validationError(`Parse failed: ${err.message}`, { reason: 'parse_failed' });
108
100
  }
109
101
  }
110
102
  }
111
103
  ```
112
104
 
113
- The contract is the single source of truth — write the recovery once, lint validates ≥5 words, the resolver carries it to every throw site that opts in. For runtime-context recovery (interpolating input values, attempted IDs, queue state), override at the throw site:
105
+ The contract is the single source of truth — write the recovery once, lint validates ≥5 words, and the framework carries it to every failure with that reason. For runtime-context recovery (interpolating input values, attempted IDs, queue state), override at the throw site:
114
106
 
115
107
  ```ts
116
108
  throw ctx.fail('no_match', `No item ${id}`, {
@@ -120,7 +112,11 @@ throw ctx.fail('no_match', `No item ${id}`, {
120
112
 
121
113
  > **A recovery hint names a capability, never an internal method.** The reader is a model whose only reachable surface is this server's tool names — it cannot call a TypeScript method, set a library option, or re-run an internal function. `Re-stage the table via registerTable()` is unfollowable and invites a hallucinated tool call; `Re-run the tool that produced this table to stage it again, or list the currently staged tables with this server's dataframe-describe tool` is actionable from where the reader sits. Name a condition the caller cannot observe — an option flag they never set — and the hint is noise for the same reason. The framework holds its own throws to this rule: the canvas SQL gate's rejections point at the dataframe-query and dataframe-describe capabilities rather than the provider methods behind them.
122
114
 
123
- `ctx.recoveryFor` 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.
115
+ #### `ctx.recoveryFor` — the entry's hint at the throw site
116
+
117
+ `ctx.recoveryFor(reason)` returns `{ recovery: { hint: <contract.recovery> } }` for a declared reason, ready to spread into `data`. Always available on `Context` (returns `{}` when no contract is attached or the reason is unknown — spread-safe with no optional chaining). On `HandlerContext<R>` it tightens to a typed signature constrained to the declared reason union.
118
+
119
+ It is not needed to put a declared hint on the wire — the fill above does that. Reach for it when the hint has to ride the thrown error itself: a test asserting `data.recovery` on the handler's own throw, or a site that deliberately sends another entry's guidance (`ctx.fail('a', msg, ctx.recoveryFor('b'))`), which the fill respects as authored.
124
120
 
125
121
  #### `severity` — log a modeled outcome below `error`
126
122
 
@@ -138,12 +134,14 @@ Values are the logger's own level names below `error` — `debug`, `info`, `noti
138
134
 
139
135
  | Surface | Under a declared severity |
140
136
  |:--------|:--------------------------|
141
- | The `Error in tool:<name>` log record | Emitted at the declared level. Same message, same structured fields. |
137
+ | The `Error in tool:<name>` log record | Emitted at the declared level. Same message, same structured fields, the stack included. |
142
138
  | `mcp.errors.classified` | Gains an `mcp.error.severity` attribute. The `reason` itself never becomes a metric attribute. |
143
139
  | `isError`, the JSON-RPC code, `structuredContent.error`, `content[]` | Byte-identical to the undeclared case. |
144
140
  | Span status, `mcp.tool.calls`, `mcp.tool.duration`, `mcp.tool.errors` | Unchanged — the call still failed, and splitting those series would redefine what an error rate means. |
145
141
 
146
- **Tools only.** Resolution happens in the tool handler factory, against the thrown error's `data.reason`. Resources declare `errors[]` but 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.
142
+ **Tools only.** Resolution happens in the tool handler factory, against the thrown error's `data.reason` — the same reason-to-entry lookup that fills `data.recovery`. Resources declare `errors[]` but write no failure record, so the field is accepted there and inert. A reason thrown below the handler that the contract never declared, an entry with no `severity`, and a non-`McpError` throw all keep `error`. A cancelled request keeps its own `info`, stack-free path regardless.
143
+
144
+ **The framework's own refusals log at `notice`.** An argument rejection (`invalid_arguments`, raised only by the schema gate before the handler runs) and a `ctx.requestInput` the connection cannot serve (`client_capability_missing`) are routine caller or connection traffic, not server faults, so their `Error in tool:<name>` record — and the failure-payload record when `LOG_TOOL_FAILURE_PAYLOADS=true` — is emitted at `notice`, and `mcp.errors.classified` counts them with `mcp.error.severity: "notice"`. Nothing to declare; an `errors[]` entry naming either reason with its own `severity` still wins. The wire envelope and `mcp.tool.rejections` are unchanged, and a schema that wrongly rejects valid calls still shows per tool on `mcp.tool.rejections`.
147
145
 
148
146
  **Skip the contract** for one-off internal tools or quick prototypes — `ctx` is plain `Context` (no `fail`) and you throw via [factories](#error-factories-fallback) directly. Behavior is identical at the wire; the contract just adds compile-time safety.
149
147
 
@@ -173,7 +171,7 @@ errors: [
173
171
  ]
174
172
  ```
175
173
 
176
- The handler doesn't catch and re-throw — letting service errors bubble unchanged keeps "logic throws, framework catches" intact. The wire payload still carries `code` + `data.reason`, and clients can switch on reason without parsing message text. What's lost is lint-time enforcement that every reason is reachable; compensate with one wire-shape test per reason.
174
+ The handler doesn't catch and re-throw — letting service errors bubble unchanged keeps "logic throws, framework catches" intact. The wire payload carries `code`, `data.reason`, and the declared entry's `recovery` as `data.recovery.hint` (filled at the handler boundary, whatever code the service picked), so clients can switch on reason without parsing message text. What's lost is lint-time enforcement that every reason is reachable; compensate with one wire-shape test per reason.
177
175
 
178
176
  **Mark the entries the service produces.** `error-contract-unthrown` reads the handler body alone, so in a handler that mixes one local precondition with service-thrown reasons it flags each service reason as dead. Add `thrownBy: 'service'` to those entries:
179
177
 
@@ -186,18 +184,7 @@ errors: [
186
184
  ]
187
185
  ```
188
186
 
189
- The field is lint-only metadata — nothing at runtime reads it, so the entry is typed, advertised, and thrown exactly as an unmarked one, and its reason stays in the `ctx.fail` / `ctx.recoveryFor` union. It suppresses the one rule that cannot see below the handler, and only for the entries it marks; the handler's own reasons keep being checked.
190
-
191
- To carry the contract `recovery` from a service throw, accept `ctx` and spread the resolver:
192
-
193
- ```ts
194
- throw validationError(message, {
195
- reason: 'parse_failed',
196
- ...ctx.recoveryFor('parse_failed'), // {} when calling tool has no matching reason
197
- });
198
- ```
199
-
200
- `ctx.recoveryFor` is always present on `Context` (no-op when no contract), so services don't need to know which tool called them — the spread is safe either way.
187
+ The field is lint-only metadata — nothing at runtime reads it, so the entry is typed, advertised, and thrown exactly as an unmarked one (its `recovery` filled like any other), and its reason stays in the `ctx.fail` / `ctx.recoveryFor` union. It suppresses the one rule that cannot see below the handler, and only for the entries it marks; the handler's own reasons keep being checked.
201
188
 
202
189
  ---
203
190
 
@@ -395,31 +382,42 @@ Checked before common patterns. Cover: AWS exception names, HTTP status codes, D
395
382
  | Layer | Pattern |
396
383
  |:------|:--------|
397
384
  | Tool/resource handlers | Throw `McpError` — no try/catch |
398
- | Handler factory (tools) | Catches all errors, normalizes to `McpError`, sets `isError: true`, mirrors error across both client surfaces (see [Error-path parity](#error-path-parity)) |
399
- | Handler factory (resources) | Catches and re-throws to the SDK, which routes through the JSON-RPC error envelope |
385
+ | Handler factory (tools) | Catches all errors, fills a declared `recovery`, normalizes to `McpError`, sets `isError: true`, adds `data.requestId`, mirrors error across both client surfaces (see [Error-path parity](#error-path-parity)) |
386
+ | Handler factory (resources) | Catches, fills a declared `recovery`, adds `data.requestId`, and re-throws to the SDK, which routes through the JSON-RPC error envelope |
387
+ | Prompt registration, HTTP transport | Log the failure, then answer the JSON-RPC error with the thrown `McpError`'s `data` plus `data.requestId` |
400
388
  | Services/setup code | `ErrorHandler.tryCatch` for structured logging and wrapping (always rethrows — never swallows) |
401
389
 
402
390
  ### Error-path parity
403
391
 
404
- MCP clients differ in which `CallToolResult` surface they forward to the agent. Tool errors mirror the success-path `format-parity` invariant — the text carries the message, the recovery hint, and the two fields a caller branches on, while the numeric `code` and `data.issues` stay JSON-only:
392
+ MCP clients differ in which `CallToolResult` surface they forward to the agent. Tool errors mirror the success-path `format-parity` invariant — the text carries the message, the recovery hint, the two fields a caller branches on, and the request id, while the numeric `code` and `data.issues` stay JSON-only:
405
393
 
406
394
  | Surface | Content | Read by |
407
395
  |:--------|:--------|:--------|
408
- | `content[]` | Text rendering: `Error: <message>`, then `Recovery: <hint>` when `data.recovery.hint` adds something the message does not already say, then `(reason <reason> · not retryable)` for whichever of `data.reason` / `data.retryable` is present | Claude Desktop and other format()-only clients |
409
- | `structuredContent.error` | JSON `{ code, message, data? }` carrying the error code, message, and any structured data from the thrown `McpError` or `ZodError` | Claude Code and other structuredContent-only clients |
396
+ | `content[]` | Text rendering: `Error: <message>`, then `Recovery: <hint>` when `data.recovery.hint` adds something the message does not already say, then `(reason <reason> · not retryable · request <id>)` for whichever of `data.reason` / `data.retryable` / `data.requestId` is present | Claude Desktop and other format()-only clients |
397
+ | `structuredContent.error` | JSON `{ code, message, data? }` carrying the error code, message, any structured data from the thrown `McpError` or `ZodError`, and `data.requestId` | Claude Code and other structuredContent-only clients |
398
+
399
+ ```text
400
+ Error: No data for 3 PMIDs
401
+
402
+ Recovery: Use pubmed_search_articles to discover valid PMIDs.
403
+
404
+ (reason no_match · not retryable · request UTFAC-QE0MB)
405
+ ```
410
406
 
411
407
  Important properties:
412
408
  - **`_meta.error` is NOT emitted.** Error code/data live on `structuredContent.error` instead. Don't read `_meta.error` in clients or tests — it doesn't exist.
413
- - **`data` propagation is restricted** to explicitly-thrown `McpError.data` 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.
414
- - **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.
415
- - **`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.
409
+ - **`data` propagation is restricted** to explicitly-thrown `McpError.data`, `ZodError.issues`, and the request id. Auto-classified plain errors (`TypeError`, network errors, etc.) emit `code`, `message`, and `data: { requestId }` only, so internal classification context never leaks to clients.
410
+ - **`data.requestId` names the request.** The framework sets it on every error envelope it builds — a tool result (handler throws, argument rejections, auth refusals, output-contract failures), a failed resource read, a failed prompt, and the JSON-RPC errors `httpErrorHandler` returns — to the `requestId` that call's log records carry. On a tool, resource, or prompt call that is a generated `XXXXX-XXXXX` token, or the client's JSON-RPC id when that id is a string; `httpErrorHandler` generates its own token, the one on its `Client error:` record. A failure reported from the client resolves to its `Error in tool:<name>` record by that value. A resource read refused before it is measured (an auth refusal, or URI variables that fail `params`) carries an id no log record shares, since resources write no failure record of their own. It is added where the envelope is built, never to the thrown `McpError.data`, so `ErrorHandler.handleError` / `tryCatch` results and the log record's `errorData` stay context-free; it replaces a thrown `data.requestId`, the way canonical fields win in log records. Two envelopes go without it: a resource `-32602` whose `data` is exactly `{ uri }` (the resource-not-found shape clients match exactly), and `runToolContract` results, which have no real request. It closes the `content[]` terms line, alone as `(request <id>)` when there is no `reason` or `retryable` — so a test pinning `content[0].text` exactly sees it.
411
+ - **Recovery hint mirroring is automatic, unless the hint repeats the message.** When the thrown `McpError` carries `data.recovery.hint`, the handler factory appends it to the `content[]` text so the markdown surface matches the JSON surface. Authors don't need to format the hint manually. The one exception is a hint the trimmed message already contains verbatim (case-sensitively) — an argument rejection whose every hint sentence restates an issue, where the hint is the message's issue text verbatim, and an author hint that restates its own message. There the line adds no next step, so it is dropped from the text; `structuredContent.error.data.recovery.hint` stays populated either way.
412
+ - **`reason`, `retryable`, and `requestId` render as a trailing term line.** `(reason malformed_id · not retryable · request UTFAC-QE0MB)` closes the text whenever `data.reason` is a non-empty string, `data.retryable` is a boolean, or `data.requestId` is a non-empty string — `retryable` for `true`, `not retryable` for `false`, in that order. None present (an `McpError` with no `data` built outside a request, as `runToolContract` does) appends nothing at all. The numeric `code` and `data.issues` stay JSON-only on purpose: the code is the one envelope field a model cannot act on, and the message already renders each issue as a sentence. A consumer test pinning `content[0].text` exactly, rather than asserting it contains the diagnostic, therefore moves for any error carrying a reason.
416
413
  - **Argument-schema rejection is a tool error with the same envelope.** An unknown root key, a wrong type, a missing required field, or a failed constraint returns `isError: true` with `structuredContent.error.code = -32602` (`InvalidParams`) and the readable `Invalid arguments for tool <name>: …` diagnostic in `content[]`. The handler never runs. Two neighbouring failures keep the protocol error path instead, arriving as a JSON-RPC error rather than a tool result: an unknown or disabled tool name, and a malformed request envelope.
417
- - **`invalid_arguments` is the framework-owned reason on every argument rejection.** The rejection carries `data.reason: "invalid_arguments"` and a `data.recovery.hint` the framework synthesizes from the Zod issues, the arguments as sent, and the root schema — an unknown 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.
418
- - **`client_capability_missing` is the other framework-owned reason.** When a handler returns `ctx.requestInput({ inputRequests: … })` on a 2025-era connection whose client declared no matching capability, `ctx.requestInput` throws this failure in place of the input-required signal, before anything reaches the wire. It is an ordinary handler throw from there on: 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`.
419
- - **A schema constraint cannot carry a *declared* reason.** Because the handler never runs, a rejection by `.max()`, `.regex()`, `.min()`, or any other Zod refinement bypasses `errors[]` entirely: it arrives as `InvalidParams` with `data.issues` under the framework's `invalid_arguments`, never the `reason` and authored `recovery` of a contract entry — so a caller has nothing tool-specific to branch on and gets only the schema-derived hint. Decide per constraint which surface it belongs on. A bound that is purely structural — the input is the wrong shape and no guidance beyond the diagnostic would help — belongs on the schema, where it also advertises itself in `inputSchema`. A bound a caller is expected to recover from belongs in the handler as `ctx.fail('reason', message, 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.
414
+ - **`invalid_arguments` is the framework-owned reason on every argument rejection.** The rejection carries `data.reason: "invalid_arguments"` and a `data.recovery.hint` the framework synthesizes from the Zod issues, the arguments as sent, and the root schema — an unknown root key names the root properties the tool does accept, an unknown key inside a nested strict object names its full path and that object's own keys (`Unknown key opts.b. opts accepts: a.`, `Unknown key items.1.b. items.1 accepts: name.`), a wrong type names the type to send instead (a fractional number on an integer field reads `Send rows as an integer, not a fractional number.`), missing fields collapse into one `Provide …` sentence, and anything else restates its diagnostic line, field path included (`start: Must be a parseable ISO 8601 date`), so identical constraints on different fields stay distinguishable. When every sentence restates an issue, the hint is the message's issue text verbatim, and its `Recovery:` line is dropped from `content[]`. Beside a framework sentence each restatement is terminated and a repeated sentence appears once: `start: Must be a parseable ISO 8601 date. Send n as a number, not a string.` When pre-validation rewrote or dropped a key the caller wrote, the rejection says so, since its issues name only the keys that were validated: `data.input` carries `{ aliased: [{ alias, target }], ignored: [...] }` — keys only, in argument order, `ignored` holding the undeclared underscore-prefixed keys the drop discarded — and the hint closes with `Validated query as targetQuery.` / `Dropped undeclared key _max.`, framework sentences that keep the `Recovery:` line. An ignore-list drop (`_meta`, `toolCallId`, a server's `input.ignoreKeys`) is a client artifact and is never reported, and a rejection the step changed nothing on carries no `data.input`. The reason renders on the closing `(reason invalid_arguments · request <id>)`; this path sets no `retryable`. Its `Error in tool:<name>` record logs at `notice`, not `error`. Authors declare nothing for this: the rejection happens before the handler and the hint is derived from the schema.
415
+ - **`client_capability_missing` is the other framework-owned reason.** When a handler returns `ctx.requestInput({ inputRequests: … })` on a 2025-era connection whose client declared no matching capability, `ctx.requestInput` throws this failure in place of the input-required signal, before anything reaches the wire. It is an ordinary handler throw from there on, logged at `notice`: measured as the failed call it is, and shaped by the family's usual error path — a tool gets `structuredContent.error.code = -32600` (`InvalidRequest`), `data.reason: "client_capability_missing"`, and a `data.recovery.hint` naming the capability; a resource read gets the same code, reason, and hint through the JSON-RPC error envelope. The hint ends at reconnecting with a client that declares the capability, since a consent gate has no argument that could stand in for its answer; a handler whose arguments can appends its own sentence per call with `ctx.requestInput(spec, { fallbackHint })`. Like `invalid_arguments`, a definition cannot declare it in `errors[]`: it names a property of the connection, not a domain outcome. See `api-context`'s `ctx.requestInput`.
416
+ - **A schema constraint cannot carry a *declared* reason.** Because the handler never runs, a rejection by `.max()`, `.regex()`, `.min()`, or any other Zod refinement bypasses `errors[]` entirely: it arrives as `InvalidParams` with `data.issues` under the framework's `invalid_arguments`, never the `reason` and authored `recovery` of a contract entry — so a caller has nothing tool-specific to branch on and gets only the schema-derived hint. Decide per constraint which surface it belongs on. A bound that is purely structural — the input is the wrong shape and no guidance beyond the diagnostic would help — belongs on the schema, where it also advertises itself in `inputSchema`. A bound a caller is expected to recover from belongs in the handler as `ctx.fail('reason', message)` against a declared `errors[]` entry, whose `recovery` the framework puts on the wire, with the limit restated in the field's `.describe()` so it is still visible before the call. Enforcing the same bound in both places is the trap: the schema wins, and the contract entry becomes unreachable while still reading as covered.
420
417
  - **A rejected value never reaches the client.** The rendered sentence distinguishes an omitted field from a wrong one (`what: Missing required field. Expected one of "os"|"cpu"` rather than the invalid-option text), and a union renders the branch that says what would have been accepted instead of Zod's `Invalid input` placeholder. Both read the arguments in-process for the absent/present bit and the arriving type only — `data.issues` ships the Zod issues as-is, and no value the caller sent is copied onto them.
421
- - **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.
422
- - **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.
418
+ - **A union branch names its own field.** Each branch issue is prefixed with the path it names relative to that branch, so two alternatives differing only in which field they require stay distinguishable: `spec: kind: Invalid option: expected one of "x"|"y"; n: Invalid input: expected number, received undefined or other: Invalid input: expected string, received undefined`. Issues *within* one branch join on `; `, across branches on ` or `, and top-level issues on `, ` — three nestings, three separators. A scalar branch carries no path and renders as before. `data.issues` still ships the raw nested Zod issues, and `data.recovery.hint` restates the same line, field path included.
419
+ - **A one-or-many union renders like the field it wraps.** Once a union branch fails below its root, every branch whose only issue is a root type mismatch is dropped — for `z.union([z.array(Item), Item])` given a list, that is the object branch saying only that the value is an array. If one branch remains, its issues render and hint under the field's path exactly as they would on a non-union field: `items.1.name: Invalid input: expected string, received boolean`, hinted `Send items.1.name as a string, not a boolean.` A missing element field is hinted `Provide items.1.name.`, and the rule applies again at every nested level. When every branch fails at its root (`items: "x"`), all of them render, joined by ` or `. `data.issues` keeps Zod's single `invalid_union` issue.
420
+ - **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 or object, or a safe integer sent for a string, is repaired and the arguments parsed once more. When that still fails and the drop discarded a key, the step retries alias-first. A call the step rescues succeeds outright and produces no error envelope; a call it cannot rescue throws the rejection above exactly as it would under `input: { coerce: false }` — same code, message, `data.issues`, `data.input`, and `data.recovery.hint` — so a discarded repair leaves no trace. An integer sent to a string field, or a stringified object to an object field, is therefore a success, not a wrong-type case — a test that needs a wrong-type rejection sends a boolean. See the `add-tool` skill for the boundaries and the per-server switches.
423
421
 
424
422
  **Handler — throw freely, no try/catch:**
425
423
 
@@ -469,7 +467,7 @@ const parsed = await ErrorHandler.tryCatch(
469
467
 
470
468
  `tryCatch` always logs and rethrows — it never swallows errors. The `fn` argument may be synchronous or return a `Promise`; both are handled via `Promise.resolve(fn())`.
471
469
 
472
- **The thrown error's `data` is wire-visible.** A handler that lets it propagate forwards it as `structuredContent.error.data` (tools) or JSON-RPC `error.data` (resources, prompts). It carries the caught `McpError`'s own `data`, `originalErrorName`, `originalMessage`, and `rootCause` (`{ name, message }`) — never a stack and never `context`: `originalStack`, the full `causeChain`, and every `context` field (`requestId`, `sessionId`, `traceId`, `tenantId`, `extra`, …) go to the log record only. A field the caller should act on belongs in the thrown `McpError`'s `data`, not in `context`.
470
+ **The thrown error's `data` is wire-visible.** A handler that lets it propagate forwards it as `structuredContent.error.data` (tools) or JSON-RPC `error.data` (resources, prompts). It carries the caught `McpError`'s own `data`, `originalErrorName`, `originalMessage`, and `rootCause` (`{ name, message }`) — never a stack and never `context`: `originalStack`, the full `causeChain`, and every `context` field (`requestId`, `sessionId`, `traceId`, `tenantId`, `extra`, …) go to the log record only. A field the caller should act on belongs in the thrown `McpError`'s `data`, not in `context`. (The handler factory adds the call's own `data.requestId` when it builds the envelope; that value never comes from `context` here.)
473
471
 
474
472
  **Options** (`Omit<ErrorHandlerOptions, 'rethrow'>`):
475
473
 
@@ -496,7 +494,7 @@ const response = await fetch(url, { signal: ctx.signal });
496
494
  if (!response.ok) {
497
495
  throw await httpErrorFromResponse(response, {
498
496
  service: 'NCBI', // included in message
499
- data: { endpoint, requestId: ctx.requestId },
497
+ data: { endpoint }, // the framework adds data.requestId
500
498
  });
501
499
  }
502
500
  ```
@@ -527,7 +525,7 @@ Also exports `httpStatusToErrorCode(status)` for sync mapping when you don't hav
527
525
 
528
526
  ## Handler-Body Lint Rules
529
527
 
530
- The startup linter (`bun run lint:mcp` and `createApp()` startup) checks handler bodies for common anti-patterns. All emit warnings (not errors) — they don't block startup but show up in `devcheck` output.
528
+ The definition linter (`bun run lint:mcp`, and devcheck's MCP Definitions step) checks handler bodies for common anti-patterns. It runs at build time, never at server startup. All emit warnings (not errors): they show up in `devcheck` output but don't fail it.
531
529
 
532
530
  | Rule | Catches |
533
531
  |:-----|:--------|
@@ -569,7 +567,6 @@ The linter validates the structure of `errors[]` and (when present) cross-checks
569
567
  | `error-contract-conformance` | warning | Handler throws a non-baseline code that isn't in the contract. Suggests adding it to `errors[]` so the contract is the canonical source of truth for declared failure modes. |
570
568
  | `error-contract-prefer-fail` | warning | Handler throws a code that **is** in the contract directly (via factory or `new McpError`) instead of through `ctx.fail(reason, …)`. Encourages routing through the typed helper so observers see consistent `data.reason` values. |
571
569
  | `error-contract-unthrown` | warning | A declared `reason` that no literal `ctx.fail('<reason>'` or `ctx.recoveryFor('<reason>'` in the handler names. Fires only when the handler already holds at least one literal `ctx.fail(`, and skips the definition entirely when either callee takes a non-literal first argument. Wire the throw, drop the entry, or mark it `thrownBy: 'service'`. |
572
- | `error-contract-recovery-unforwarded` | warning | A literal `ctx.fail('<reason>', …)` site carrying neither `ctx.recoveryFor('<reason>')` nor its own `recovery` key, so the declared hint reaches neither client surface. One diagnostic per site; skips a site whose data argument the scan cannot read. |
573
570
 
574
571
  ### Baseline codes (auto-allowed)
575
572
 
@@ -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.19"
7
+ version: "1.21"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -15,10 +15,10 @@ The linter validates tool, resource, and prompt definitions against the MCP spec
15
15
 
16
16
  | Entry point | When | On failure |
17
17
  |:------------|:-----|:-----------|
18
- | `bun run lint:mcp` | Manual or CI | Prints errors + warnings, exits non-zero on errors. |
18
+ | `bun run lint:mcp` | Manual or CI | Imports every definition file, prints errors + warnings, exits non-zero on errors. A file that fails to import is an error ([`definition-import-failed`](#definition-import-failed)), never a skip. |
19
19
  | `bun run devcheck` | Pre-commit workflow | Wraps `lint:mcp` alongside typecheck, format, `bun audit`, `bun outdated`. |
20
20
 
21
- Both surface the same `LintReport` from `validateDefinitions()` (exported from `@cyanheads/mcp-ts-core/linter`). Each diagnostic has a stable `rule` ID — that's the anchor you land on via the `See: framework-skills/api-linter/SKILL.md#<rule>` breadcrumb appended to every message.
21
+ Both surface the same `LintReport` from `validateDefinitions()` (exported from `@cyanheads/mcp-ts-core/linter`), plus two load errors the CLI raises itself, because `validateDefinitions()` only receives what already loaded: `definition-import-failed` and `server-json-parse`. Each diagnostic has a stable `rule` ID — that's the anchor you land on via the `See: framework-skills/api-linter/SKILL.md#<rule>` breadcrumb appended to every message.
22
22
 
23
23
  **Severity:**
24
24
  - **error** — MUST-level spec violation; blocks `devcheck`.
@@ -42,7 +42,7 @@ Grouped by family. Jump to any rule ID via its anchor.
42
42
 
43
43
  | Family | Rules | Section |
44
44
  |:-------|:------|:--------|
45
- | Definition | `definition-invalid` | [Definition rules](#definition-rules) |
45
+ | Definition | `definition-invalid`, `definition-import-failed` | [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
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) |
@@ -53,7 +53,7 @@ Grouped by family. Jump to any rule ID via its anchor.
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
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-recovery-unforwarded` | [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
 
@@ -69,6 +69,23 @@ Fires when a `tools`, `resources`, or `prompts` array passed to `validateDefinit
69
69
 
70
70
  **Fix:** remove the empty slot, or ensure every element of the array is a real definition object (e.g. `[makeFooTool(), enabled ? makeBarTool() : null].filter(Boolean)`).
71
71
 
72
+ ### definition-import-failed
73
+
74
+ **Severity:** error
75
+
76
+ Fires when a discovered definition file (`*.tool.ts`, `*.resource.ts`, `*.prompt.ts`, `*.app-tool.ts`, `*.app-resource.ts` under `src/mcp-server/` or `examples/mcp-server/`) rejects on `import()`: a package that the file, or anything it imports, needs cannot be resolved, the file has a syntax error, or code throws at module load. None of that file's definitions can be checked, so the run fails rather than passing without them. The other files are still imported and linted, so one run reports every import failure alongside the rule diagnostics. The `lint:mcp` CLI (`scripts/lint-mcp.ts`) raises it; `validateDefinitions()` never does, since a programmatic caller does its own imports.
77
+
78
+ ```text
79
+ ✗ [definition-import-failed] src/mcp-server/tools/definitions/query.tool.ts: Cannot find package '@duckdb/node-api' imported from …
80
+ ```
81
+
82
+ **Fix**, by cause:
83
+
84
+ - **An optional peer dependency** imported at the top of the definition or of a service it imports: install the peer wherever `lint:mcp` and `devcheck` run (a `devDependency` is enough), or make the import lazy — `await import('<pkg>')` inside the handler or service method that uses it, so loading the definition never touches the package. The framework's own Tier 3 subpaths already lazy-load their peers; importing them from a definition needs nothing installed.
85
+ - **A throw at module load** — reading config, constructing a client, or awaiting a network call at top level: move the work into `setup()`, a service's init, or the handler. Definitions must import without side effects.
86
+ - **A syntax error**: fix it. `devcheck`'s typecheck reports the same file with a location.
87
+ - **Running the script under plain `node`**: Node's type stripping does not rewrite a relative `./x.js` specifier to `x.ts`, so a definition that imports a sibling module fails to load. Run it under Bun — `bun run lint:mcp`, as `devcheck` does.
88
+
72
89
  ---
73
90
 
74
91
  ## Format parity
@@ -472,19 +489,20 @@ Catches `readOnlyHint: true` with **any** explicit `destructiveHint` value (even
472
489
 
473
490
  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.
474
491
 
475
- Five conditions, all decidable from the definition:
492
+ Six conditions, all decidable from the definition:
476
493
 
477
494
  | Condition | Example |
478
495
  |:--|:--|
479
496
  | 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 |
480
497
  | An alias's target must be a declared key | `inputAliases: { q: 'searchQuery' }` when the schema declares `query` |
498
+ | An alias's target must not be `headerParam`-designated | `inputAliases: { region: 'regionCode' }` with `regionCode: headerParam(z.string(), 'Region')` — the rewrite never targets a header-mirrored field, since the SDK checks the `Mcp-Param-Region` header against the body the caller sent, so the caller gets `Unknown key region` |
481
499
  | Two declared keys must not case-fold to one name | `z.object({ maxResults, max_results })` — no alias can resolve between them |
482
500
  | An alias must not case-fold to a declared key other than its target | `inputAliases: { max_results: 'query' }` alongside a declared `maxResults` |
483
501
  | Two aliases must not case-fold to one name with different targets | `inputAliases: { 'search-term': 'query', search_term: 'maxResults' }` |
484
502
 
485
- 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.
503
+ 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. The header check reads each variant's own designations, so an alias onto a designated field inside one variant is reported beside that variant's `header-param-designation` error; a designation deeper than the root never blocks an alias, which only ever names a root key.
486
504
 
487
- **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.
505
+ **Fix:** point the alias at an existing key, rename the key it shadows, or drop the alias. For a header target, drop the alias or the `headerParam` designation — the field cannot be both. Also fires when `inputAliases` is not an object of non-empty string targets.
488
506
 
489
507
  Silent when no `inputAliases` is declared — the case-style half needs no declaration and declines ambiguity on its own.
490
508
 
@@ -596,6 +614,7 @@ Validates the `server.json` manifest at project root against the [MCP server man
596
614
 
597
615
  | Rule ID | Severity | What it checks |
598
616
  |:--------|:---------|:---------------|
617
+ | `server-json-parse` | error | `server.json` exists but does not parse as JSON, so none of the rules below can run. Raised by the `lint:mcp` CLI, which reads the file; a missing `server.json` is skipped |
599
618
  | `server-json-type` | error | `server.json` must be a JSON object, not an array or primitive |
600
619
  | `server-json-name-required` | error | `name` must be present and non-empty |
601
620
  | `server-json-name-length` | error | `name` length 3–200 characters |
@@ -847,7 +866,7 @@ Fires when an entry's optional `severity` field is present but isn't one of `deb
847
866
 
848
867
  **Severity:** error
849
868
 
850
- Fires when an entry's `recovery` field is missing or not a string. `recovery` is the agent's next-move guidance when this failure fires — it flows to the wire via `ctx.recoveryFor`.
869
+ Fires when an entry's `recovery` field is missing or not a string. `recovery` is the agent's next-move guidance when this failure fires — the handler factory sends it as `data.recovery.hint` with any failure carrying the entry's reason and no hint of its own.
851
870
 
852
871
  ### error-contract-recovery-empty
853
872
 
@@ -929,37 +948,13 @@ async handler(input, ctx) {
929
948
  }
930
949
  ```
931
950
 
932
- The field is lint-only metadata: `ctx.fail`, `ctx.recoveryFor`, the `severity` lookup, and the advertised error envelope never read it, so a marked entry is typed, advertised, and thrown exactly as an unmarked one. Prefer it over the workarounds that also silence the rule — moving the literal `ctx.fail` into a module-level helper turns the whole tool off, handler-local reasons included.
951
+ The field is lint-only metadata: `ctx.fail`, `ctx.recoveryFor`, the recovery fill, the `severity` lookup, and the advertised error envelope never read it, so a marked entry is typed, advertised, and thrown exactly as an unmarked one. Prefer it over the workarounds that also silence the rule — moving the literal `ctx.fail` into a module-level helper turns the whole tool off, handler-local reasons included.
933
952
 
934
953
  **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(` or `ctx.recoveryFor(` whose first argument is not a string literal — a variable, a template literal, a map lookup — makes the named set unknowable, and the whole definition is skipped rather than guessed at.
935
954
 
936
955
  **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. Still silent without a marker: a `createFail(errors)` resolver built outside the handler, and an aliased `const fail = ctx.fail`.
937
956
 
938
- ### error-contract-recovery-unforwarded
939
-
940
- **Severity:** warning
941
-
942
- Fires per literal `ctx.fail('<reason>', …)` site that does not put the contract's `recovery` on the wire.
943
-
944
- `recovery` is required on every `errors[]` entry, but reaching the client with it is opt-in — the throw site forwards `ctx.recoveryFor('<reason>')`, or passes its own `recovery` key. A site that does neither ships `reason` and `retryable` with no hint, and since the framework mirrors `data.recovery.hint` into the error `content[]`, both client surfaces lose it together. Nothing else catches this: the contract is declared, `lint:mcp` passes, and an error-path test asserting `code` and `reason` passes with the hint absent.
945
-
946
- **Fix:** forward the resolver at the site named in the diagnostic.
947
-
948
- ```ts
949
- // warns
950
- throw ctx.fail('rate_limited', 'Upstream rate limit exceeded');
951
-
952
- // clean — any of
953
- throw ctx.fail('rate_limited', msg, { ...ctx.recoveryFor('rate_limited') });
954
- throw ctx.fail('rate_limited', msg, ctx.recoveryFor('rate_limited'));
955
- throw ctx.fail('rate_limited', msg, { recovery: { hint: `Retry in ${waitSeconds}s.` } });
956
- ```
957
-
958
- **Per site, not per reason.** A handler wiring one of six throws is covered at one of them, so each site is judged on its own argument list. Two sites naming one reason, one forwarding and one bare, produce exactly one diagnostic. A site whose only resolver names a *different* reason warns too, naming both — the caller would otherwise get another failure mode's guidance.
959
-
960
- **Bails.** A non-literal first argument on either `ctx.fail(` or `ctx.recoveryFor(` skips the whole definition, as it does for `error-contract-unthrown`. A resolver sitting outside every fail span — a hoisted `const hint = ctx.recoveryFor('x')` — skips that reason, since the binding is assembled where the scan cannot follow it. A data argument the scan cannot read skips that one site: an identifier (`ctx.fail('r', msg, data)`), a call other than the resolver, or an object literal spreading another value (`{ ...details }`), any of which may carry `recovery` already. An object literal of plain keys carrying no `recovery` still warns.
961
-
962
- **Heuristic limitations:** same `handler.toString()` scan as `error-contract-unthrown`, so a call written inside a comment or nested in another literal is not a site, and a failure thrown below the handler is invisible. The rule speaks only for the sites it sees, which is why it stays a warning.
957
+ No rule checks that a throw site forwards the declared `recovery`: the handler factory fills `data.recovery.hint` from the entry for any failure carrying its reason and no hint of its own, so a bare `ctx.fail('<reason>')` and a service throw both reach the client with it. See `api-errors`.
963
958
 
964
959
  ---
965
960
 
@@ -4,7 +4,7 @@ description: >
4
4
  Stand up a persistent, self-refreshing local mirror of a bulk upstream dataset with the MirrorService (@cyanheads/mcp-ts-core/mirror). Use when a server wraps a large or slow API and should query a synced local index (embedded SQLite + FTS5) instead of paginating the live API per request.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.2"
7
+ version: "1.3"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -24,6 +24,7 @@ const papers = defineMirror({
24
24
  name: 'arxiv-papers',
25
25
  store: sqliteMirrorStore({
26
26
  path: config.mirrorPath,
27
+ table: 'papers', // primary table; FTS index is `papers_fts`
27
28
  primaryKey: 'id',
28
29
  columns: { id: 'TEXT', title: 'TEXT', authors: 'TEXT', abstract: 'TEXT', updated: 'TEXT' },
29
30
  fts: ['title', 'authors', 'abstract'], // opt-in FTS5 external-content index
@@ -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.14"
7
+ version: "1.16"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -73,7 +73,7 @@ A failed flush is logged as a warning and the logger still closes, so the final
73
73
  |:--------|:-----|:-----|
74
74
  | `SIGTERM` / `SIGINT` | `shutdown(signal)`, then an explicit exit | `0`, or `1` when the backstop fires |
75
75
  | `uncaughtException` / `unhandledRejection` | `shutdown(signal)`, then an explicit exit | `1` |
76
- | stdin EOF, stdio transport | `shutdown('STDIN_EOF')`, then an explicit exit | `0`, backstop or not |
76
+ | stdin EOF, stdio transport | the SDK transport closes itself, aborting in-flight requests unanswered; then `shutdown('STDIN_EOF')` and an explicit exit | `0`, backstop or not |
77
77
  | a second signal during shutdown | none — the handlers are already detached | the OS default (`143` / `130`) |
78
78
  | `ServerHandle.shutdown()` called directly | the same drain | none — exit-free by contract |
79
79
 
@@ -138,7 +138,7 @@ All custom metrics are namespaced `mcp.*` (or `process.*` / `http.client.*` wher
138
138
  | `mcp.tool.param.usage` | counter | `{uses}` | `mcp.tool.name`, `mcp.tool.param` (top-level keys supplied by caller) |
139
139
  | `mcp.input.ignored_key` | counter | `{keys}` | `mcp.tool.name`, `mcp.input.ignore_rule` (the ignore-list entry that matched, or `underscore_prefix`) |
140
140
  | `mcp.input.aliased` | counter | `{keys}` | `mcp.tool.name`, `mcp.input.target` (the declared key), `mcp.input.alias_kind` (`declared`/`case_style`) |
141
- | `mcp.input.coerced` | counter | `{calls}` | `mcp.tool.name`, `mcp.input.coercion` (`stringified_array`) |
141
+ | `mcp.input.coerced` | counter | `{calls}` | `mcp.tool.name`, `mcp.input.coercion` (`stringified_array`/`stringified_object`/`integer_as_string`) |
142
142
  | `mcp.resource.reads` | counter | `{reads}` | `mcp.resource.name`, `mcp.resource.success` |
143
143
  | `mcp.resource.duration` | histogram | `ms` | `mcp.resource.name`, `mcp.resource.success` |
144
144
  | `mcp.resource.errors` | counter | `{errors}` | `mcp.resource.name` |
@@ -153,7 +153,7 @@ All custom metrics are namespaced `mcp.*` (or `process.*` / `http.client.*` wher
153
153
 
154
154
  **Rejections and cancellations.** A call refused before the handler runs — argument validation (`-32602`) or the inline `auth` check (`-32005` missing scope, `-32006` no auth context) — never reaches the measured region, so it is absent from `mcp.tool.calls`, `mcp.tool.duration`, and `mcp.tool.errors` and counts once on `mcp.tool.rejections` instead, labelled with the code and category the caller received. `mcp.tool.outcome` separates a caller hang-up from a failure: `cancelled` for a `RequestCancelled` (`-32011`, always paired with `error_category="client"`), `error` for any other failure, `ok` for a success or an `input_required` round. `mcp.tool.success` and `error_category` keep their meaning, so existing `sum()` queries are unchanged. An error rate that excludes hang-ups filters on `mcp.tool.outcome!="cancelled"`; the failure rate a caller sees is `(errors + rejections) / (calls + rejections)`. Resources and prompts carry neither split.
155
155
 
156
- 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.
156
+ The three `mcp.input.*` counters are the pre-validation step's metrics. Each marks a call the strict `input` schema would otherwise have rejected: a key rewritten to its canonical spelling, a client-added root key dropped, or a value repaired after the parse failed — a stringified array or object, or an integer sent for a string. `mcp.input.coerced` adds one per repaired call per kind, not per repaired value: a call repairing an array and an object adds one to each `mcp.input.coercion` series, and a call repairing three arrays adds one. A call the step rescues carries nothing about it in its response, so a client artifact spreading across a fleet shows up here first. The counters describe the arguments the handler receives: when a call is retried with the alias stage first (see `add-tool`), the key the retry rewrote counts on `mcp.input.aliased` and never also on `mcp.input.ignored_key`, and a rejected call counts the attempt its rejection reports — the retry's when it ran. The counters are not the only record: every stage writes a debug log naming the key or the repair kinds, the opt-in failure-payload record ([below](#failed-call-payloads)) keeps a failed call's arguments as the caller sent them, and a rejected call reports its rewrites and underscore-rule drops to the caller as `data.input` (see `api-errors`). All three are lazy: a server whose callers never trip a stage emits no series at all.
157
157
 
158
158
  **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.
159
159
 
@@ -214,15 +214,17 @@ A dashboard reading `error_category` alone therefore no longer needs to special-
214
214
  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:
215
215
 
216
216
  - The `Error in tool:<name>` log record is emitted at that level instead of `error`, with the same message and structured fields.
217
- - `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.
217
+ - `mcp.errors.classified` gains `mcp.error.severity` on that record. It is set only when a severity resolved below `error`.
218
218
 
219
- 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`.
219
+ The framework's own refusals resolve one without a declaration: an argument rejection (`invalid_arguments`) and a `ctx.requestInput` the client connection cannot serve (`client_capability_missing`) log at `notice`, so even a server that declares no severity sees `mcp.error.severity: "notice"` on those `mcp.errors.classified` increments — a bounded split a dashboard can use to separate caller rejections from faults. An argument rejection opens no execution span and reaches no call counter either way; it still counts once on `mcp.tool.rejections`.
220
+
221
+ 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 write no failure record of their own. A cancelled request keeps its own `info`, stack-free path whatever the contract declares. See `api-errors`.
220
222
 
221
223
  ### Errors, rate limits, HTTP client
222
224
 
223
225
  | Metric | Type | Unit | Attributes |
224
226
  |:-------|:-----|:-----|:-----------|
225
- | `mcp.errors.classified` | counter | `{errors}` | `mcp.error.classified_code` (JSON-RPC code), `mcp.error.category` (`upstream`/`server`/`client`, as in [Error category](#error-category)), `operation`, and `mcp.error.severity` when the failure's declared severity resolved |
227
+ | `mcp.errors.classified` | counter | `{errors}` | `mcp.error.classified_code` (JSON-RPC code), `mcp.error.category` (`upstream`/`server`/`client`, as in [Error category](#error-category)), `operation`, and `mcp.error.severity` when a tool failure's level resolved below `error` — a declared severity, or `notice` for an `invalid_arguments` / `client_capability_missing` refusal |
226
228
  | `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) |
227
229
  | `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) |
228
230
 
@@ -245,17 +247,19 @@ Auto-registered when `process.memoryUsage` / `process.uptime` / `perf_hooks` are
245
247
 
246
248
  Every framework log record carries `requestId`, `traceId`, `spanId`, and `tenantId` from the request context, so every log line is searchable by trace. `@opentelemetry/instrumentation-pino` does not touch these records: it patches only a `pino` loaded after the SDK starts. To ship the records to the same backend as traces, set `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` (see Enabling export).
247
249
 
248
- For domain logging inside handlers, use `ctx.log` (`debug`/`info`/`notice`/`warning`/`error`) — auto-includes `requestId`, `traceId`, `tenantId`, `spanId`. The completion log emitted at the end of every handler carries a `metrics` payload, with fields tuned to each surface:
250
+ For domain logging inside handlers, use `ctx.log` (`debug`/`info`/`notice`/`warning`/`error`) — auto-includes `requestId`, `traceId`, `tenantId`, `spanId`. The completion log emitted at the end of every handler — at `info`, whatever the outcome — carries a `metrics` payload, with fields tuned to each surface:
249
251
 
250
252
  | Handler | Log message | `metrics` fields |
251
253
  |:--------|:------------|:-----------------|
252
254
  | Tool | `Tool execution finished.` | `durationMs`, `isSuccess`, `errorCode`, `inputBytes`, `outputBytes`, plus `partialSuccess` / `batchSucceeded` / `batchFailed` when the result is a partial-success batch |
253
255
  | Resource | `Resource read finished.` | `durationMs`, `isSuccess`, `errorCode`, `outputBytes`, `uri`, `mimeType` |
254
- | Prompt | `Prompt generation finished.` (or `failed.`) | `durationMs`, `isSuccess`, `errorCode`, `inputBytes`, `outputBytes`, `messageCount` |
256
+ | Prompt | `Prompt generation finished.` | `durationMs`, `isSuccess`, `errorCode`, `inputBytes` (0 for a prompt declaring no arguments), `outputBytes`, `messageCount` |
257
+
258
+ A failed tool call or prompt adds exactly one `Error in tool:<name>` / `Error in prompt:<name>` record. Each call — prompts included — logs under its own `requestId`, and the client receives that value as `data.requestId` on the call's error envelope, so a reported failure resolves to its records.
255
259
 
256
260
  ### Failed-call payloads
257
261
 
258
- Off by default. With `LOG_TOOL_FAILURE_PAYLOADS=true`, a failed tool call writes one more record right after its `Error in tool:<name>` record: message `Tool failure payload: <name>`, the same request context (`requestId`, `traceId`, `spanId`, `toolName`), and the same level, a declared `severity` included.
262
+ Off by default. With `LOG_TOOL_FAILURE_PAYLOADS=true`, a failed tool call writes one more record right after its `Error in tool:<name>` record: message `Tool failure payload: <name>`, the same request context (`requestId`, `traceId`, `spanId`, `toolName`), and the same level, a declared `severity` and the `notice` of an argument rejection included. A payload record below `MCP_LOG_LEVEL` is dropped with its `Error in tool:` record, so at `warning` or above an argument rejection writes neither.
259
263
 
260
264
  | Field | Content |
261
265
  |:------|:--------|