@cyanheads/mcp-ts-core 0.13.6 → 0.13.8

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 (267) hide show
  1. package/AGENTS.md +5 -5
  2. package/CLAUDE.md +5 -5
  3. package/README.md +57 -52
  4. package/biome.json +1 -1
  5. package/changelog/0.13.x/0.13.7.md +77 -0
  6. package/changelog/0.13.x/0.13.8.md +101 -0
  7. package/config/tsconfig.base.json +2 -2
  8. package/dist/config/index.d.ts +9 -0
  9. package/dist/config/index.d.ts.map +1 -1
  10. package/dist/config/index.js +61 -11
  11. package/dist/config/index.js.map +1 -1
  12. package/dist/core/app.d.ts.map +1 -1
  13. package/dist/core/app.js +35 -6
  14. package/dist/core/app.js.map +1 -1
  15. package/dist/core/context.d.ts +9 -1
  16. package/dist/core/context.d.ts.map +1 -1
  17. package/dist/core/context.js +17 -16
  18. package/dist/core/context.js.map +1 -1
  19. package/dist/core/worker.d.ts +2 -0
  20. package/dist/core/worker.d.ts.map +1 -1
  21. package/dist/core/worker.js +9 -1
  22. package/dist/core/worker.js.map +1 -1
  23. package/dist/linter/rules/enrichment-rules.d.ts +3 -2
  24. package/dist/linter/rules/enrichment-rules.d.ts.map +1 -1
  25. package/dist/linter/rules/enrichment-rules.js +9 -2
  26. package/dist/linter/rules/enrichment-rules.js.map +1 -1
  27. package/dist/linter/rules/handler-body-rules.d.ts.map +1 -1
  28. package/dist/linter/rules/handler-body-rules.js +10 -4
  29. package/dist/linter/rules/handler-body-rules.js.map +1 -1
  30. package/dist/linter/rules/schema-rules.d.ts +5 -0
  31. package/dist/linter/rules/schema-rules.d.ts.map +1 -1
  32. package/dist/linter/rules/schema-rules.js +44 -17
  33. package/dist/linter/rules/schema-rules.js.map +1 -1
  34. package/dist/mcp-server/handlerContext.d.ts +6 -0
  35. package/dist/mcp-server/handlerContext.d.ts.map +1 -1
  36. package/dist/mcp-server/handlerContext.js +3 -0
  37. package/dist/mcp-server/handlerContext.js.map +1 -1
  38. package/dist/mcp-server/outputContract.d.ts +33 -0
  39. package/dist/mcp-server/outputContract.d.ts.map +1 -0
  40. package/dist/mcp-server/outputContract.js +43 -0
  41. package/dist/mcp-server/outputContract.js.map +1 -0
  42. package/dist/mcp-server/prompts/prompt-registration.d.ts.map +1 -1
  43. package/dist/mcp-server/prompts/prompt-registration.js +6 -3
  44. package/dist/mcp-server/prompts/prompt-registration.js.map +1 -1
  45. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
  46. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +10 -2
  47. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
  48. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +16 -5
  49. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  50. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +70 -14
  51. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  52. package/dist/mcp-server/transports/auth/lib/authUtils.js +4 -1
  53. package/dist/mcp-server/transports/auth/lib/authUtils.js.map +1 -1
  54. package/dist/mcp-server/transports/auth/strategies/jwtStrategy.d.ts.map +1 -1
  55. package/dist/mcp-server/transports/auth/strategies/jwtStrategy.js +1 -1
  56. package/dist/mcp-server/transports/auth/strategies/jwtStrategy.js.map +1 -1
  57. package/dist/mcp-server/transports/auth/strategies/oauthStrategy.d.ts.map +1 -1
  58. package/dist/mcp-server/transports/auth/strategies/oauthStrategy.js +2 -5
  59. package/dist/mcp-server/transports/auth/strategies/oauthStrategy.js.map +1 -1
  60. package/dist/mcp-server/transports/http/httpErrorHandler.d.ts.map +1 -1
  61. package/dist/mcp-server/transports/http/httpErrorHandler.js +15 -5
  62. package/dist/mcp-server/transports/http/httpErrorHandler.js.map +1 -1
  63. package/dist/mcp-server/transports/http/sessionStore.d.ts.map +1 -1
  64. package/dist/mcp-server/transports/http/sessionStore.js +2 -2
  65. package/dist/mcp-server/transports/http/sessionStore.js.map +1 -1
  66. package/dist/services/canvas/core/CanvasRegistry.js +1 -1
  67. package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
  68. package/dist/services/canvas/core/DataCanvas.d.ts.map +1 -1
  69. package/dist/services/canvas/core/DataCanvas.js +7 -5
  70. package/dist/services/canvas/core/DataCanvas.js.map +1 -1
  71. package/dist/services/canvas/core/canvasFactory.d.ts.map +1 -1
  72. package/dist/services/canvas/core/canvasFactory.js +2 -2
  73. package/dist/services/canvas/core/canvasFactory.js.map +1 -1
  74. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts.map +1 -1
  75. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js +25 -16
  76. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js.map +1 -1
  77. package/dist/services/llm/providers/openrouter.provider.js +1 -1
  78. package/dist/services/llm/providers/openrouter.provider.js.map +1 -1
  79. package/dist/services/speech/providers/elevenlabs.provider.js +3 -3
  80. package/dist/services/speech/providers/elevenlabs.provider.js.map +1 -1
  81. package/dist/services/speech/providers/whisper.provider.d.ts.map +1 -1
  82. package/dist/services/speech/providers/whisper.provider.js +5 -5
  83. package/dist/services/speech/providers/whisper.provider.js.map +1 -1
  84. package/dist/storage/core/IStorageProvider.d.ts +5 -2
  85. package/dist/storage/core/IStorageProvider.d.ts.map +1 -1
  86. package/dist/storage/core/StorageService.d.ts.map +1 -1
  87. package/dist/storage/core/StorageService.js +3 -6
  88. package/dist/storage/core/StorageService.js.map +1 -1
  89. package/dist/storage/core/providerHelpers.d.ts +29 -8
  90. package/dist/storage/core/providerHelpers.d.ts.map +1 -1
  91. package/dist/storage/core/providerHelpers.js +49 -11
  92. package/dist/storage/core/providerHelpers.js.map +1 -1
  93. package/dist/storage/core/storageFactory.d.ts.map +1 -1
  94. package/dist/storage/core/storageFactory.js +12 -15
  95. package/dist/storage/core/storageFactory.js.map +1 -1
  96. package/dist/storage/core/storageValidation.d.ts +13 -13
  97. package/dist/storage/core/storageValidation.d.ts.map +1 -1
  98. package/dist/storage/core/storageValidation.js +49 -125
  99. package/dist/storage/core/storageValidation.js.map +1 -1
  100. package/dist/storage/providers/cloudflare/d1Provider.d.ts.map +1 -1
  101. package/dist/storage/providers/cloudflare/d1Provider.js +9 -7
  102. package/dist/storage/providers/cloudflare/d1Provider.js.map +1 -1
  103. package/dist/storage/providers/cloudflare/kvProvider.d.ts +2 -0
  104. package/dist/storage/providers/cloudflare/kvProvider.d.ts.map +1 -1
  105. package/dist/storage/providers/cloudflare/kvProvider.js +12 -10
  106. package/dist/storage/providers/cloudflare/kvProvider.js.map +1 -1
  107. package/dist/storage/providers/cloudflare/r2Provider.d.ts.map +1 -1
  108. package/dist/storage/providers/cloudflare/r2Provider.js +11 -8
  109. package/dist/storage/providers/cloudflare/r2Provider.js.map +1 -1
  110. package/dist/storage/providers/fileSystem/fileSystemProvider.d.ts +1 -0
  111. package/dist/storage/providers/fileSystem/fileSystemProvider.d.ts.map +1 -1
  112. package/dist/storage/providers/fileSystem/fileSystemProvider.js +14 -12
  113. package/dist/storage/providers/fileSystem/fileSystemProvider.js.map +1 -1
  114. package/dist/storage/providers/inMemory/inMemoryProvider.d.ts +6 -1
  115. package/dist/storage/providers/inMemory/inMemoryProvider.d.ts.map +1 -1
  116. package/dist/storage/providers/inMemory/inMemoryProvider.js +15 -10
  117. package/dist/storage/providers/inMemory/inMemoryProvider.js.map +1 -1
  118. package/dist/storage/providers/supabase/supabaseProvider.d.ts.map +1 -1
  119. package/dist/storage/providers/supabase/supabaseProvider.js +5 -1
  120. package/dist/storage/providers/supabase/supabaseProvider.js.map +1 -1
  121. package/dist/testing/fuzz.d.ts.map +1 -1
  122. package/dist/testing/fuzz.js +7 -1
  123. package/dist/testing/fuzz.js.map +1 -1
  124. package/dist/testing/index.d.ts +21 -6
  125. package/dist/testing/index.d.ts.map +1 -1
  126. package/dist/testing/index.js +57 -10
  127. package/dist/testing/index.js.map +1 -1
  128. package/dist/types-global/errors.d.ts +7 -4
  129. package/dist/types-global/errors.d.ts.map +1 -1
  130. package/dist/types-global/errors.js.map +1 -1
  131. package/dist/utils/formatting/codeSpan.d.ts +27 -0
  132. package/dist/utils/formatting/codeSpan.d.ts.map +1 -0
  133. package/dist/utils/formatting/codeSpan.js +42 -0
  134. package/dist/utils/formatting/codeSpan.js.map +1 -0
  135. package/dist/utils/formatting/diffFormatter.d.ts.map +1 -1
  136. package/dist/utils/formatting/diffFormatter.js +7 -15
  137. package/dist/utils/formatting/diffFormatter.js.map +1 -1
  138. package/dist/utils/formatting/markdownBuilder.d.ts +12 -5
  139. package/dist/utils/formatting/markdownBuilder.d.ts.map +1 -1
  140. package/dist/utils/formatting/markdownBuilder.js +14 -2
  141. package/dist/utils/formatting/markdownBuilder.js.map +1 -1
  142. package/dist/utils/formatting/partialResult.d.ts +28 -2
  143. package/dist/utils/formatting/partialResult.d.ts.map +1 -1
  144. package/dist/utils/formatting/partialResult.js +46 -2
  145. package/dist/utils/formatting/partialResult.js.map +1 -1
  146. package/dist/utils/formatting/tableFormatter.d.ts.map +1 -1
  147. package/dist/utils/formatting/tableFormatter.js +5 -9
  148. package/dist/utils/formatting/tableFormatter.js.map +1 -1
  149. package/dist/utils/formatting/treeFormatter.d.ts.map +1 -1
  150. package/dist/utils/formatting/treeFormatter.js +5 -9
  151. package/dist/utils/formatting/treeFormatter.js.map +1 -1
  152. package/dist/utils/internal/error-handler/errorHandler.d.ts +21 -8
  153. package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
  154. package/dist/utils/internal/error-handler/errorHandler.js +70 -38
  155. package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
  156. package/dist/utils/internal/error-handler/mappings.d.ts +18 -1
  157. package/dist/utils/internal/error-handler/mappings.d.ts.map +1 -1
  158. package/dist/utils/internal/error-handler/mappings.js +23 -1
  159. package/dist/utils/internal/error-handler/mappings.js.map +1 -1
  160. package/dist/utils/internal/error-handler/types.d.ts +2 -0
  161. package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
  162. package/dist/utils/internal/logger.d.ts +75 -3
  163. package/dist/utils/internal/logger.d.ts.map +1 -1
  164. package/dist/utils/internal/logger.js +181 -52
  165. package/dist/utils/internal/logger.js.map +1 -1
  166. package/dist/utils/internal/performance.d.ts +16 -1
  167. package/dist/utils/internal/performance.d.ts.map +1 -1
  168. package/dist/utils/internal/performance.js +59 -20
  169. package/dist/utils/internal/performance.js.map +1 -1
  170. package/dist/utils/network/fetchWithTimeout.d.ts +11 -5
  171. package/dist/utils/network/fetchWithTimeout.d.ts.map +1 -1
  172. package/dist/utils/network/fetchWithTimeout.js +50 -23
  173. package/dist/utils/network/fetchWithTimeout.js.map +1 -1
  174. package/dist/utils/network/retry.d.ts +16 -8
  175. package/dist/utils/network/retry.d.ts.map +1 -1
  176. package/dist/utils/network/retry.js +19 -8
  177. package/dist/utils/network/retry.js.map +1 -1
  178. package/dist/utils/overflow/outlineOnOverflow.d.ts +18 -2
  179. package/dist/utils/overflow/outlineOnOverflow.d.ts.map +1 -1
  180. package/dist/utils/overflow/outlineOnOverflow.js +28 -3
  181. package/dist/utils/overflow/outlineOnOverflow.js.map +1 -1
  182. package/dist/utils/pagination/pagination.d.ts +3 -1
  183. package/dist/utils/pagination/pagination.d.ts.map +1 -1
  184. package/dist/utils/pagination/pagination.js +10 -2
  185. package/dist/utils/pagination/pagination.js.map +1 -1
  186. package/dist/utils/parsing/csvParser.d.ts.map +1 -1
  187. package/dist/utils/parsing/csvParser.js +4 -2
  188. package/dist/utils/parsing/csvParser.js.map +1 -1
  189. package/dist/utils/parsing/htmlExtractor.js +1 -1
  190. package/dist/utils/parsing/htmlExtractor.js.map +1 -1
  191. package/dist/utils/parsing/jsonParser.d.ts.map +1 -1
  192. package/dist/utils/parsing/jsonParser.js +3 -1
  193. package/dist/utils/parsing/jsonParser.js.map +1 -1
  194. package/dist/utils/parsing/xmlParser.d.ts.map +1 -1
  195. package/dist/utils/parsing/xmlParser.js +3 -1
  196. package/dist/utils/parsing/xmlParser.js.map +1 -1
  197. package/dist/utils/parsing/yamlParser.d.ts.map +1 -1
  198. package/dist/utils/parsing/yamlParser.js +3 -1
  199. package/dist/utils/parsing/yamlParser.js.map +1 -1
  200. package/dist/utils/security/idGenerator.d.ts.map +1 -1
  201. package/dist/utils/security/idGenerator.js +20 -4
  202. package/dist/utils/security/idGenerator.js.map +1 -1
  203. package/dist/utils/security/sanitization.d.ts +46 -15
  204. package/dist/utils/security/sanitization.d.ts.map +1 -1
  205. package/dist/utils/security/sanitization.js +203 -96
  206. package/dist/utils/security/sanitization.js.map +1 -1
  207. package/dist/utils/telemetry/attributes.d.ts +16 -1
  208. package/dist/utils/telemetry/attributes.d.ts.map +1 -1
  209. package/dist/utils/telemetry/attributes.js +16 -1
  210. package/dist/utils/telemetry/attributes.js.map +1 -1
  211. package/dist/utils/telemetry/instrumentation.d.ts +13 -3
  212. package/dist/utils/telemetry/instrumentation.d.ts.map +1 -1
  213. package/dist/utils/telemetry/instrumentation.js +104 -17
  214. package/dist/utils/telemetry/instrumentation.js.map +1 -1
  215. package/framework-skills/add-app-tool/SKILL.md +12 -18
  216. package/framework-skills/add-prompt/SKILL.md +3 -1
  217. package/framework-skills/add-provider/SKILL.md +14 -4
  218. package/framework-skills/add-resource/SKILL.md +3 -3
  219. package/framework-skills/add-tool/SKILL.md +22 -7
  220. package/framework-skills/api-auth/SKILL.md +3 -1
  221. package/framework-skills/api-canvas/SKILL.md +4 -4
  222. package/framework-skills/api-config/SKILL.md +9 -5
  223. package/framework-skills/api-context/SKILL.md +10 -7
  224. package/framework-skills/api-errors/SKILL.md +20 -11
  225. package/framework-skills/api-linter/SKILL.md +14 -10
  226. package/framework-skills/api-telemetry/SKILL.md +38 -13
  227. package/framework-skills/api-testing/SKILL.md +25 -15
  228. package/framework-skills/api-utils/SKILL.md +10 -10
  229. package/framework-skills/api-utils/references/formatting.md +1 -1
  230. package/framework-skills/api-utils/references/parsing.md +2 -2
  231. package/framework-skills/api-utils/references/security.md +13 -10
  232. package/framework-skills/code-simplifier/SKILL.md +31 -18
  233. package/framework-skills/design-mcp-server/SKILL.md +62 -35
  234. package/framework-skills/git-wrapup/SKILL.md +19 -10
  235. package/framework-skills/maintenance/SKILL.md +3 -3
  236. package/framework-skills/orchestrations/SKILL.md +1 -1
  237. package/framework-skills/orchestrations/workflows/greenfield-build.md +15 -8
  238. package/framework-skills/polish-docs-meta/SKILL.md +2 -2
  239. package/framework-skills/polish-docs-meta/references/package-meta.md +1 -1
  240. package/framework-skills/polish-docs-meta/references/readme.md +4 -3
  241. package/framework-skills/release-and-publish/SKILL.md +7 -5
  242. package/framework-skills/release-pr-review/SKILL.md +18 -1
  243. package/framework-skills/report-issue-framework/SKILL.md +2 -2
  244. package/framework-skills/report-issue-local/SKILL.md +3 -3
  245. package/framework-skills/security-pass/SKILL.md +11 -3
  246. package/framework-skills/techniques/SKILL.md +1 -1
  247. package/framework-skills/techniques/references/outline-on-overflow.md +12 -7
  248. package/framework-skills/tool-defs-analysis/SKILL.md +3 -3
  249. package/package.json +30 -36
  250. package/scripts/check-skill-versions.ts +103 -22
  251. package/scripts/devcheck.ts +4 -3
  252. package/scripts/lint-packaging.ts +38 -1
  253. package/templates/.env.example +7 -1
  254. package/templates/.github/ISSUE_TEMPLATE/feature_request.yml +1 -1
  255. package/templates/AGENTS.md +2 -2
  256. package/templates/CLAUDE.md +2 -2
  257. package/templates/Dockerfile +30 -10
  258. package/templates/package.json +4 -3
  259. package/templates/src/mcp-server/prompts/definitions/echo.prompt.ts +2 -4
  260. package/templates/src/mcp-server/resources/definitions/echo-app-ui.app-resource.ts +51 -14
  261. package/templates/src/mcp-server/resources/definitions/echo.resource.ts +1 -1
  262. package/templates/src/mcp-server/tools/definitions/echo-app.app-tool.ts +2 -3
  263. package/templates/src/mcp-server/tools/definitions/echo.tool.ts +1 -1
  264. package/dist/utils/telemetry/index.d.ts +0 -12
  265. package/dist/utils/telemetry/index.d.ts.map +0 -1
  266. package/dist/utils/telemetry/index.js +0 -12
  267. package/dist/utils/telemetry/index.js.map +0 -1
@@ -4,7 +4,7 @@ description: >
4
4
  Scaffold a new MCP tool definition. Use when the user asks to add a tool, create a new tool, or implement a new capability for the server.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.29"
7
+ version: "2.30"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -24,13 +24,13 @@ Tools use the `tool()` builder from `@cyanheads/mcp-ts-core`. Each tool lives in
24
24
 
25
25
  ## Naming
26
26
 
27
- Tools use lowercase snake_case with a canonical server/domain prefix: `{server}_{verb}_{noun}` — 3 words.
27
+ Tools use lowercase snake_case with a canonical server/domain prefix, `{server}_{verb}_{noun}` by default. Drop the noun only when the verb is a complete action on its own (`git_pull`, `git_status`): if `{server}_{verb}` leaves "…what?" unanswered — `search` what? `connect` to what? — the noun is missing. The full rule is the Name row of the `design-mcp-server` Design table.
28
28
 
29
29
  Examples: `pubmed_search_articles`, `pubmed_fetch_fulltext`, `clinicaltrials_find_eligible`.
30
30
 
31
31
  The server prefix is judged on clarity, not length: the brand name or the plain well-known word for the domain both pass (`pubmed_`, `patents_`); an abbreviation fails only when it reads as something else out of context (`loc_`, `ct_`). A fourth segment is fine when the noun is inherently two words (`openfda_search_device_clearances`). When a name resists the schema — can't pick a verb, noun feels generic, the *verb* wants a second word — that's usually a signal the scope is fuzzy; split the tool, rename, or reconsider.
32
32
 
33
- For shape selection (Workflow or Instruction variants — standard single-action tools are the default), see the `design-mcp-server` skill's Tool shapes section.
33
+ For shape selection (Workflow, Instruction, or Reference variants — standard single-action tools are the default), see the `design-mcp-server` skill's Tool shapes section.
34
34
 
35
35
  ## Template
36
36
 
@@ -243,7 +243,7 @@ const { enableWrites } = getServerConfig();
243
243
 
244
244
  // The suggestion is emitted only under the config that registers its target.
245
245
  const nextToolSuggestions = enableWrites
246
- ? [{ toolName: 'brapi_submit_observations', args: { studyDbId } }]
246
+ ? [{ toolName: 'brapi_submit_observations', reason: 'Record the observations collected for this study.', args: { studyDbId } }]
247
247
  : [];
248
248
 
249
249
  return {
@@ -344,11 +344,11 @@ The handler dispatches on the discriminator and TypeScript narrows `input` to th
344
344
 
345
345
  What reaches the wire is `{"type": "object", "oneOf": [<branch>, …]}`: branches intact, each with its own `required` list and a `const`-tagged discriminator, `additionalProperties: false` on every one. Identical bytes on a 2025-11-25 and a 2026-07-28 connection — the legacy projection inspects `outputSchema` alone and never rewrites an input root.
346
346
 
347
- Three constraints:
347
+ Four constraints:
348
348
 
349
349
  - **The union must be discriminated.** A bare `z.union(...)` is rejected: with no literal-tagged key the model has nothing to choose a branch by, and every variant's `required` would read as applying at once.
350
350
  - **`output` stays a flat `z.object`** — see the widening section below for why a non-object output root breaks the success path. When the *result* shape varies by mode, use a `kind` discriminator with presence-based optional fields and render each arm on field presence in `format()`.
351
- - **Portability is unmeasured at the parameter root.** `schema-root-oneof-portability` (strict mode only) says so; for Anthropic clients the union is the better shape, and flattening is the escape hatch if you target the widest vendor matrix.
351
+ - **Claude clients flatten the union root.** The Anthropic Messages API rejects a top-level `oneOf` in `input_schema`, so Claude clients rewrite the root before the model sees it — and the rewrite keeps only the first branch's properties, with `required: []`. A tool that must work in Claude clients takes a flat `z.object()` with an enum discriminator, optional per-mode fields, each mode's required fields named in the discriminator's `.describe()`, and the combination checked in the handler. `schema-root-oneof-portability` (strict mode only) flags the union root. Tracked in [#510](https://github.com/cyanheads/mcp-ts-core/issues/510).
352
352
  - **A union root rules out `headerParam`.** See below — the branches sit under `oneOf`, which the reachability rule excludes.
353
353
 
354
354
  ### `headerParam` mirrors an argument into a request header
@@ -539,7 +539,7 @@ async handler(input, ctx) {
539
539
 
540
540
  Single-item tools don't need this — they either succeed or throw. The partial success question only arises with array inputs.
541
541
 
542
- **Telemetry:** The framework automatically detects this pattern — when a handler result contains a non-empty `failed` array, the span gets `mcp.tool.partial_success`, `mcp.tool.batch.succeeded_count`, and `mcp.tool.batch.failed_count` attributes. No manual instrumentation needed.
542
+ **Telemetry:** The framework automatically detects this pattern — when a handler result contains a non-empty `failed` array, the span gets `mcp.tool.partial_success`, `mcp.tool.batch.succeeded_count` (from the `succeeded` array), and `mcp.tool.batch.failed_count` attributes. No manual instrumentation needed. An `output` built with `partialResultSchema()` from `/utils` is read under its own `failedKey`/`succeededKey` instead — also after `.extend()`, `.pick()`, `.omit()`, or a `.shape` spread. `.partial()` and `.required()` rebuild the fields, so a schema derived that way falls back to the literal keys.
543
543
 
544
544
  ### Empty results need context
545
545
 
@@ -811,6 +811,21 @@ async handler(input, ctx) {
811
811
 
812
812
  The same applies to optional arrays — use `?.length` guards so empty arrays are skipped, not passed through.
813
813
 
814
+ When an optional string field carries a validator (`.regex()` for a date, `.min(1)` for a cursor), a permissive schema would drop the validator and a strict one would reject the blank. Keep both by mapping the blank to `undefined` *before* the validator runs:
815
+
816
+ ```typescript
817
+ /** A blank from a form client is "unset", never a value to validate. */
818
+ const blankAsUnset = <T extends z.ZodType>(schema: T) =>
819
+ z.preprocess((value) => (value === '' ? undefined : value), schema);
820
+
821
+ input: z.object({
822
+ d1: blankAsUnset(z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()).describe('Earliest date, YYYY-MM-DD.'),
823
+ cursor: blankAsUnset(z.string().regex(/^[1-9]\d*$/).optional()).describe('Opaque continuation from the previous page.'),
824
+ }),
825
+ ```
826
+
827
+ `toJSONSchema` emits only the inner schema for a preprocess pipe in both `io` modes, so the advertised `pattern` is unchanged; `''` parses to an absent key, a real value still hits the validator, and the handler needs no extra guard.
828
+
814
829
  **Required fields are different.** If a string field is required and must be non-empty to be meaningful, `.min(1)` is correct — the client shouldn't have submitted the form without filling it in.
815
830
 
816
831
  ### Match response density to context budget
@@ -4,7 +4,7 @@ description: >
4
4
  Authentication, authorization, and multi-tenancy patterns for `@cyanheads/mcp-ts-core`. Use when implementing auth scopes on tools/resources, configuring auth modes (none/jwt/oauth), working with JWT/OAuth env vars, or understanding how tenantId flows through ctx.state.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.3"
7
+ version: "1.4"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -34,6 +34,8 @@ const myTool = tool('my_tool', {
34
34
 
35
35
  When `MCP_AUTH_MODE=none`, auth checks are skipped and defaults are allowed.
36
36
 
37
+ A failed check returns `Forbidden` (-32005, `Insufficient permissions.`) or, when auth is enabled but the request carries no auth context, `Unauthorized` (-32006). Neither carries `data`: the required, granted, and missing scope names stay in the server log, so a caller cannot enumerate scopes from the error.
38
+
37
39
  ---
38
40
 
39
41
  ## Dynamic auth
@@ -4,7 +4,7 @@ description: >
4
4
  DataCanvas primitive reference — a Tier 3 SQL/analytical workspace for tabular MCP servers, backed by DuckDB. Use when registering tables from upstream APIs, running ad-hoc SQL across them, and exporting results. Covers the acquire → register → query → export flow, per-table TTL, the token-sharing pattern for multi-agent collaboration, env config, and Cloudflare Workers fail-closed behavior.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.3"
7
+ version: "2.5"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -168,9 +168,9 @@ await instance.registerTable('recent_fetch', rows, { ttlMs: 30 * 60 * 1000 });
168
168
 
169
169
  Run SQL across registered tables. Returns at most `rowLimit` rows (default 10 000). When the result exceeds `rowLimit`, the response carries `truncated: true` and `rowCount` reflects the number of materialized rows (not the full result set). For full result sets and exact counts, pass `registerAs` — the result is materialized as a new canvas table; the response carries a `preview` slice and the exact `rowCount`.
170
170
 
171
- Querying a table that does not exist throws `NotFound` (`data.reason: 'missing_table'`) with a recovery hint to re-run the tool that staged the table or list what is currently staged. This happens when a table has expired (per-table TTL), been dropped, or the name is mistyped. The error is `NotFound`, not `ValidationError` — agents should re-stage, not fix the SQL shape. A well-formed but unknown or expired `canvas_id` fails the same way (`data.reason: 'canvas_not_found'`, with its own recovery hint) — thrown by `acquire()` and every canvas operation. An id that fails the format check is a different failure: `ValidationError` with `data.reason: 'canvas_id_malformed'`, raised before the lookup on each of the three entry points that take a caller-supplied id — `acquire`, `drop` (which previously reported it as a silent `false`), and `importFrom`'s source id.
171
+ Querying a table that does not exist throws `NotFound` (`data.reason: 'missing_table'`, `data.tableName` carrying the full name as DuckDB reports it, spaces included) with a recovery hint to re-run the tool that staged the table or list what is currently staged. This happens when a table has expired (per-table TTL), been dropped, or the name is mistyped. The error is `NotFound`, not `ValidationError` — agents should re-stage, not fix the SQL shape. Only a read-shaped statement qualifies (one starting `SELECT`, `WITH`, or DuckDB's FROM-first `FROM`): a `DROP`, `DELETE`, `INSERT`, `UPDATE`, or `ALTER` naming a missing table is `non_select_statement`, since re-staging would not make it pass. A well-formed but unknown or expired `canvas_id` fails the same way (`data.reason: 'canvas_not_found'`, with its own recovery hint) — thrown by `acquire()` and every canvas operation. An id that fails the format check is a different failure: `ValidationError` with `data.reason: 'canvas_id_malformed'`, raised before the lookup on each of the three entry points that take a caller-supplied id — `acquire`, `drop` (which previously reported it as a silent `false`), and `importFrom`'s source id.
172
172
 
173
- A `SELECT` that parses but fails to prepare for any other reason — a mistyped column, an unknown function, an invalid expression — throws `ValidationError` (`data.reason: 'invalid_sql'`) and preserves the DuckDB binder detail in `data.binderMessage` (e.g. `Referenced column "x" not found...`, often with a candidate suggestion). This is distinct from `non_select_statement`, reserved for statements that genuinely aren't `SELECT`s — here the shape is fine, so the agent should fix the named column or function.
173
+ A `SELECT` that parses but fails to prepare for any other reason — a mistyped column, an unknown scalar or table function, type, or collation, a schema the canvas does not have, an invalid expression — throws `ValidationError` (`data.reason: 'invalid_sql'`) and preserves the DuckDB binder detail in `data.binderMessage` (e.g. `Referenced column "x" not found...`, often with a candidate suggestion). This is distinct from `non_select_statement`, reserved for statements that genuinely aren't `SELECT`s — here the shape is fine, so the agent should fix the named column or function. DuckDB's FROM-first form (`FROM t`, `FROM t SELECT a`) is a `SELECT`: it passes the gate, and one that fails to prepare is classified the same way.
174
174
 
175
175
  A `SELECT` that prepares and then fails on the staged data throws `ValidationError` (`data.reason: 'sql_execution_error'`) with the engine message preserved and a hint pointing at `TRY_CAST` or filtering the offending rows. The split follows DuckDB's own execution-error classes — `Conversion Error`, `Invalid Input Error`, `Out of Range Error` — matched on the message prefix. Engine faults (`IO Error`, `INTERNAL Error`, `Out of Memory Error`, and anything unmatched) stay `DatabaseError`, so an export or import failing on I/O is never reported to the caller as bad SQL. `DUCKDB_ERROR_REASONS` exports these alongside `SQL_GATE_REASONS`.
176
176
 
@@ -511,7 +511,7 @@ The merged iterable streams — the helper does not double-buffer the full sourc
511
511
  | Sync or async | Caller-supplied | Forwarded to `registerTable` as-is |
512
512
  | Sync or async | Omitted | Helper infers via `inferSchemaFromRows` over preview buffer + sentinel |
513
513
 
514
- When the preview budget is small (single-digit rows) and the sniff window matters, pass `schema` explicitly — the helper's window is only as large as the preview budget allows.
514
+ Pass `schema` explicitly whenever a column's type can't be read off the first rows — a fractional column whose leading values are all `0` or `null` sniffs as `BIGINT` (or `VARCHAR`), and the appender then truncates or stringifies every later value without an error. The sniff window is only as large as the preview budget, so shrinking `previewChars` widens the exposure; the same applies to `registerTable` called without a schema. Derive the schema from the row type once and pass it to both calls.
515
515
 
516
516
  ### Cancellation and partial state
517
517
 
@@ -4,7 +4,7 @@ description: >
4
4
  Reference for core and server configuration in `@cyanheads/mcp-ts-core`. Covers env var tables with defaults, priority order, server-specific Zod schema pattern, and Workers lazy-parsing requirement.
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
  ---
@@ -93,7 +93,9 @@ await createApp({ sessionMode: { default: 'stateful', require: 'stateful' } });
93
93
  |:--------|:-----------------|:--------|:------|
94
94
  | `NODE_ENV` | `environment` | `development` | Aliases: `dev`→`development`, `prod`→`production`, `test`→`testing` |
95
95
  | `MCP_LOG_LEVEL` | `logLevel` | `debug` | Aliases: `warn`→`warning`, `err`→`error`, `fatal`/`silent`→`emerg`, `trace`→`debug`, `information`→`info` |
96
- | `LOGS_DIR` | `logsPath` | `<app-root>/logs` | Node.js only; absolute paths are used verbatim, relative ones resolve against the application root (see Core config) — never the framework's install directory |
96
+ | `LOGS_DIR` | `logsPath` | `<app-root>/logs` | Node.js only; absolute paths are used verbatim, relative ones resolve against the application root (see Core config) — never the framework's install directory. A file under it that cannot be opened (read-only mount, another user's directory) is dropped at startup with one `warning` naming it and the error code; stderr and the other files keep logging |
97
+ | `LOG_TOOL_FAILURE_PAYLOADS` | `logToolFailurePayloads` | `false` | Opt-in. Each failed tool call also writes a `Tool failure payload: <tool>` record carrying `toolInput` (the arguments as sent) and `toolResult` (the `CallToolResult` returned) as redacted JSON strings, at the call's error-record level. Reaches every log destination — stderr, `combined.log`, and OTLP when `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` is set. Redaction is by key name only, so a secret inside a free-form value (a query, a message) is logged. Record shape: `api-telemetry` Logs |
98
+ | `LOG_TOOL_FAILURE_PAYLOAD_MAX_BYTES` | `logToolFailurePayloadMaxBytes` | `16384` | Cap per payload, in UTF-8 bytes. A longer one is cut on a character boundary and flagged with `toolInputTruncated` / `toolResultTruncated` |
97
99
 
98
100
  ### Transport
99
101
 
@@ -209,10 +211,12 @@ Activated when `SUPABASE_URL` is set.
209
211
  | `OTEL_ENABLED` | `openTelemetry.enabled` | `false` | Enable OpenTelemetry export |
210
212
  | `OTEL_SERVICE_NAME` | `openTelemetry.serviceName` | `createApp` `name` → `package.json` `name` | Seeded from `createApp({ name })` when unset; an env value wins |
211
213
  | `OTEL_SERVICE_VERSION` | `openTelemetry.serviceVersion` | `package.json` `version` | |
212
- | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `openTelemetry.tracesEndpoint` | — | OTLP traces endpoint URL |
213
- | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | `openTelemetry.metricsEndpoint` | — | OTLP metrics endpoint URL |
214
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | — | — | OTLP/HTTP base URL; resolves `tracesEndpoint` to `<base>/v1/traces` and `metricsEndpoint` to `<base>/v1/metrics` (path prefix kept) when the signal-specific variable is unset |
215
+ | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `openTelemetry.tracesEndpoint` | — | OTLP traces endpoint URL; overrides the base, used as-is |
216
+ | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | `openTelemetry.metricsEndpoint` | — | OTLP metrics endpoint URL; overrides the base, used as-is |
217
+ | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | `openTelemetry.logsEndpoint` | — | OTLP logs endpoint URL; the only switch for log record export, never derived from the base. Needs the optional peers `@opentelemetry/sdk-logs`, `@opentelemetry/exporter-logs-otlp-http`, `@opentelemetry/api-logs` |
214
218
  | `OTEL_TRACES_SAMPLER_ARG` | `openTelemetry.samplingRatio` | `1.0` | 0–1; fraction of traces to export |
215
- | `OTEL_LOG_LEVEL` | `openTelemetry.logLevel` | `INFO` | OTel SDK internal log level: `NONE` \| `ERROR` \| `WARN` \| `INFO` \| `DEBUG` \| `VERBOSE` \| `ALL` |
219
+ | `OTEL_LOG_LEVEL` | `openTelemetry.logLevel` | `INFO` | OTel SDK internal log level: `NONE` \| `ERROR` \| `WARN` \| `INFO` \| `DEBUG` \| `VERBOSE` \| `ALL`; aliases `warning`→`WARN`, `err`→`ERROR`, `information`→`INFO`. Diag output goes to stderr at every level |
216
220
 
217
221
  ---
218
222
 
@@ -4,7 +4,7 @@ description: >
4
4
  Canonical reference for the unified `Context` object passed to every tool and resource handler in `@cyanheads/mcp-ts-core`. Covers the full interface, its `RequestContext` base, all sub-APIs (`ctx.log`, `ctx.state`, `ctx.requestInput`, `ctx.inputs`, `ctx.enrich`, `ctx.content`), and when to use each.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.5"
7
+ version: "2.7"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -109,7 +109,7 @@ await fetchUser('123', ctx); // ctx is a Context — no conversion
109
109
 
110
110
  `RequestContext` has **no index signature**. Its fields are exactly: `auth`, `extra`, `operation`, `requestId`, `sessionId`, `spanId`, `tenantId`, `timestamp`, `traceId`. A misspelled canonical field (`tenatId`) is a compile error instead of a silently-ignored key.
111
111
 
112
- Operation-specific correlation data goes in **`extra`** — the one deliberate open bag (`Readonly<Record<string, unknown>>`). The logger flattens `extra` into the emitted line, so log output looks the same as a top-level spread.
112
+ Operation-specific correlation data goes in **`extra`** — the one deliberate open bag (`Readonly<Record<string, unknown>>`). The logger flattens `extra` into the emitted line, so log output looks the same as a top-level spread — except that an `extra` key named like a canonical field the context sets never replaces it.
113
113
 
114
114
  ### Adding correlation data
115
115
 
@@ -149,7 +149,7 @@ Never re-open the shape to get past a type error: no index signature, no widenin
149
149
 
150
150
  Request-scoped structured logger. Every log line is automatically annotated with `requestId`, `traceId`, and `tenantId` — no manual spreading needed.
151
151
 
152
- **Dual-sink.** Each call writes to Pino *and* mirrors onto the MCP wire as a `notifications/message` (the framework advertises the `logging` capability, and the SDK filters by the level the client set via `logging/setLevel`). The wire payload is `{ message, ...data }`; `ctx.log.error` adds `error: <message>`. Delivery is fire-and-forget — a client that never upgraded to SSE, set a higher level, or already disconnected drops the notification, and a failed send never fails the handler. Treat `ctx.log` as client-visible: it is no longer a server-only sink, so don't log anything there you wouldn't put in a tool result.
152
+ **Dual-sink.** Each call writes to Pino *and* mirrors onto the MCP wire as a `notifications/message` (the framework advertises the `logging` capability, and the SDK filters by the level the client set via `logging/setLevel`). The wire payload is `{ message, ...data }`; `ctx.log.error` adds `error: <message>`. `message` and `error` are reserved wire keys, written after `data`: a `message` in `data` never replaces the log line on the wire, and on `ctx.log.error` with an `Error` the `error` key is always that error's message. The process log line still carries the caller's own fields, except one reusing a canonical name the context already sets (`requestId`, `traceId`, `spanId`, `tenantId`, …) — there the context's value wins, so the line stays correlated to its request. Delivery is fire-and-forget — a client that never upgraded to SSE, set a higher level, or already disconnected drops the notification, and a failed send never fails the handler. Treat `ctx.log` as client-visible: it is no longer a server-only sink, so don't log anything there you wouldn't put in a tool result.
153
153
 
154
154
  ### Methods
155
155
 
@@ -210,7 +210,7 @@ interface ContextState {
210
210
  ### Usage
211
211
 
212
212
  ```ts
213
- // Store — accepts any serializable value, no manual JSON.stringify needed
213
+ // Store — accepts any JSON-serializable value, no manual JSON.stringify needed
214
214
  await ctx.state.set('item/123', { name: 'Widget', count: 42 });
215
215
  await ctx.state.set('session/xyz', token, { ttl: 3600 }); // TTL in seconds
216
216
 
@@ -236,6 +236,7 @@ if (page.cursor) { /* more pages available */ }
236
236
 
237
237
  - Throws `McpError(InvalidRequest)` if `tenantId` is missing. Won't happen in stdio (any auth mode) or HTTP+`MCP_AUTH_MODE=none` — both default to `'default'`. Can happen in HTTP+`MCP_AUTH_MODE=jwt`/`oauth` when the token lacks a `tid` claim (intentional fail-closed: distinct authenticated callers must not silently share state).
238
238
  - Keys are tenant-prefixed internally; handlers never need to namespace manually.
239
+ - **Values round-trip as JSON** on every provider, `in-memory` included: reads return the JSON form, so a `Date` comes back as its ISO string, a `Map` as `{}`, and a returned object never shares identity with the one written. Validate reads with a schema that matches the stored form (`z.string()` for a date, not `z.date()`). A `bigint`, a cyclic reference, or a top-level `undefined`, function, or symbol throws `McpError(SerializationError)` before anything is written; in `setMany`, one such value rejects the whole batch.
239
240
  - **Key charset:** `^[a-zA-Z0-9_.\-/]+$`, 1024 chars max, no `..`. Slashes are the namespace separator — a colon (`item:123`) throws `McpError(ValidationError)` on every call. The rule covers `list` prefixes and every key in a batch operation. `createMockContext().state` enforces it identically, so an illegal key fails in the test rather than in a deployment.
240
241
  - **Workers persistence:** The `in-memory` provider loses data on cold starts. Use `cloudflare-kv`, `cloudflare-r2`, or `cloudflare-d1` for durable storage in Workers.
241
242
 
@@ -712,9 +713,11 @@ For tools that cap a list (i.e. have a `limit`/`per_page`/`page_size`/`max_resul
712
713
 
713
714
  ```ts
714
715
  enrichment: {
715
- truncated: z.boolean().describe('True when the list was capped.'),
716
- shown: z.number().describe('Number of items returned.'),
717
- cap: z.number().describe('The limit that was applied.'),
716
+ // Optional: truncated() writes these only when the cap is hit, and a required
717
+ // enrichment field left unset fails the effective-output parse on every complete result.
718
+ truncated: z.boolean().optional().describe('True when the list was capped.'),
719
+ shown: z.number().optional().describe('Number of items returned.'),
720
+ cap: z.number().optional().describe('The limit that was applied.'),
718
721
  truncationCeiling: z.number().optional().describe('Upper bound for omitted items (threshold bound).'),
719
722
  },
720
723
  async handler(input, ctx) {
@@ -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.15"
7
+ version: "1.17"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -205,6 +205,8 @@ throw validationError(message, {
205
205
 
206
206
  Throw when the server has authoritative classification — auth failure, rate limit, schema violation, upstream 5xx, missing required input. Don't throw when "this looks wrong" depends on intent the server can't see. For mutators, surface raw pre- and post-mutation observable state in the response and let the agent decide whether it matches intent — the server can detect that the file shrunk, but only the agent knows whether it was supposed to. Tell: defensive code justified as a free rider on other work — audit it standalone, and it usually doesn't earn its keep.
207
207
 
208
+ A best-effort call that catches and degrades must still rethrow on `ctx.signal?.aborted`: `catch (err) { if (ctx.signal?.aborted) throw err; return degraded(); }`. One example is an enrichment lookup whose failure should return the primary result with a notice. The factory maps a cancelled handler to `RequestCancelled` only when the handler throws. A catch-all degrade turns the caller's cancellation into a "successful" response and logs a false failure warning.
209
+
208
210
  ---
209
211
 
210
212
  ## Error Factories (fallback)
@@ -254,7 +256,7 @@ throw new McpError(code, message?, data?, options?)
254
256
 
255
257
  - `code` — a `JsonRpcErrorCode` enum value
256
258
  - `message` — optional human-readable description of the failure
257
- - `data` — optional structured context (plain object)
259
+ - `data` — optional structured data (plain object), returned to the client verbatim. Pass the explicit fields the caller acts on (the rejected key, a limit, a `reason`), never `ctx` or another request context: a handler `ctx` carries request metadata and, after an elicitation round, what the user typed. Framework helpers follow the same rule — a storage, parser, or formatter failure carries only its offending field or a `reason`, whatever context you pass them.
258
260
  - `options` — optional `{ cause?: unknown }` for error chaining
259
261
 
260
262
  **Example:**
@@ -312,23 +314,28 @@ Use factories or `McpError` directly when the code must be exact — auto-classi
312
314
 
313
315
  The framework applies these steps in order — first match wins:
314
316
 
315
- 1. **Request signal aborted** — `ctx.signal.aborted` is `true` when the handler unwinds → `RequestCancelled`. Resolved by the tool and resource handler factories before the thrown value is classified at all, so it outranks every step below, `McpError` included: the caller withdrew the request, and what the handler threw on the way out does not change that. Covers every shape an abort leaves behind — a `notifications/cancelled` `reason` string, the `DOMException` named `AbortError` a reason-less cancellation produces, a service's own `McpError`, and the SDK's `SdkError(ConnectionClosed)` on transport close. The accepted cost is that an unrelated fault raised after the abort is recorded as a cancellation too; it is bounded, because the SDK writes no response for a request whose signal it aborted. A handler that throws while the signal is live is untouched by this step.
317
+ 1. **Request signal aborted** — `ctx.signal.aborted` is `true` when the handler unwinds → `RequestCancelled`. Resolved before the thrown value is classified at all — by the tool and resource handler factories, and by the HTTP transport's error handler against the inbound request's signal, which catches a caller that hangs up before any handler runs (mid-body, say) and answers it 499 — so it outranks every step below, `McpError` included: the caller withdrew the request, and what the handler threw on the way out does not change that. Covers every shape an abort leaves behind — a `notifications/cancelled` `reason` string, the `DOMException` named `AbortError` a reason-less cancellation produces, a service's own `McpError`, and the SDK's `SdkError(ConnectionClosed)` on transport close. The accepted cost is that an unrelated fault raised after the abort is recorded as a cancellation too; it is bounded, because the SDK writes no response for a request whose signal it aborted. A handler that throws while the signal is live is untouched by this step.
316
318
  2. **`McpError` instance** — `error.code` is preserved as-is; no classification needed.
317
- 3. **SDK transport-closed rejection** — an `SdkError` carrying `SdkErrorCode.ConnectionClosed` → `RequestCancelled`. The SDK rejects every in-flight request when the transport closes, which is what a client disconnect looks like from inside a handler. Matched on the code, not the message: one of its wordings says "aborted" and would otherwise be caught by the generic abort pattern in step 6 and read as a `Timeout`. Still the rule for a throw raised where no request signal is in scope — a service, an outbound leg, a background task.
318
- 4. **JS constructor name** — matched against a fixed table (e.g. `ZodError` → `ValidationError`, `SyntaxError` → `ValidationError`). Note: `TypeError` is intentionally excluded — runtime TypeErrors are programmer errors, not validation failures.
319
- 5. **Provider-specific patterns** — HTTP status codes, AWS exception names, Supabase, OpenRouter. Checked before common patterns because they are more specific (e.g. `status code 429` beats the generic `rate limit` pattern).
320
- 6. **Common message/name patterns** — broad keyword patterns covering auth, not-found, validation, etc. First match wins; order matters.
321
- 7. **`AbortError` name** — `error.name === 'AbortError'` → `Timeout`.
322
- 8. **Fallback** — `InternalError`.
319
+ 3. **SDK transport-closed rejection** — an `SdkError` carrying `SdkErrorCode.ConnectionClosed` → `RequestCancelled`. The SDK rejects every in-flight request when the transport closes, which is what a client disconnect looks like from inside a handler. Matched on the code, not the message: one of its wordings says "aborted" and would otherwise be caught by the generic abort pattern in step 7 and read as a `Timeout`. Still the rule for a throw raised where no request signal is in scope — a service, an outbound leg, a background task.
320
+ 4. **Engine resource limit** — a `RangeError` whose **whole** message is one the engine raises when it runs out of a resource → `InternalError`: `Maximum call stack size exceeded` (JavaScriptCore adds a trailing period) and the maximum string size (V8 `Invalid string length`, JavaScriptCore `Out of memory`). A handler that recurses without bound names nothing a caller can change, so it is a server fault. Every other `RangeError` — `new Array(-1)`, `(1).toFixed(101)`, an invalid date, `1n / 0n`, or one whose message merely contains a limit text — continues to step 5.
321
+ 5. **JS constructor name** — matched against a fixed table (e.g. `ZodError` → `ValidationError`, `SyntaxError` → `ValidationError`). Note: `TypeError` is intentionally excluded — runtime TypeErrors are programmer errors, not validation failures.
322
+ 6. **Provider-specific patterns** — HTTP status codes, AWS exception names, Supabase, OpenRouter. Checked before common patterns because they are more specific (e.g. `status code 429` beats the generic `rate limit` pattern).
323
+ 7. **Common message/name patterns** — broad keyword patterns covering auth, not-found, validation, etc. First match wins; order matters.
324
+ 8. **`AbortError` name** — `error.name === 'AbortError'` → `Timeout`.
325
+ 9. **Fallback** — `InternalError`.
323
326
 
324
327
  However it is reached, a `RequestCancelled` is logged at `info` with no stack — neither the thrown value's own nor one reached through its cause chain. Step 1 settles the completion log too, which carries `metrics.errorCode: "-32011"` alongside `isSuccess: false`; a raw `SdkError` that reaches the code through step 3 alone is not an `McpError`, so that log still reads `UNHANDLED_ERROR`.
325
328
 
329
+ The code this ladder picks is the one the caller receives, and it is also the origin every error counter records: `mcp.tool.error_category`, `mcp.prompt.error_category`, and `mcp.error.category` on `mcp.errors.classified` all bucket that same code, so a plain `Error('Request timed out')` files as `upstream` everywhere, never `server` on one counter and `upstream` on another. See `api-telemetry`'s Error category.
330
+
331
+ **The framework's own output-contract parses are not caller errors.** A result that breaks the definition's `output` schema (tools and resources) or its `enrichment` block fails as `InternalError` (`-32603`), with a message naming the definition and the contract — `Tool my_tool returned output that does not match its output schema: items.0.id: …` — and no `data`. It is the handler's bug, so it files as `server`, not the `ValidationError` a raw `ZodError` would get. A `ZodError` the handler throws from its own validation keeps `ValidationError`.
332
+
326
333
  ### JS Constructor Name Mappings
327
334
 
328
335
  | Constructor | Mapped Code |
329
336
  |:------------|:------------|
330
337
  | `SyntaxError` | `ValidationError` |
331
- | `RangeError` | `ValidationError` |
338
+ | `RangeError` | `ValidationError` (an engine resource limit is settled first, as `InternalError` — step 4) |
332
339
  | `URIError` | `ValidationError` |
333
340
  | `ZodError` | `ValidationError` |
334
341
  | `ReferenceError` | `InternalError` |
@@ -462,12 +469,14 @@ const parsed = await ErrorHandler.tryCatch(
462
469
 
463
470
  `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())`.
464
471
 
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`.
473
+
465
474
  **Options** (`Omit<ErrorHandlerOptions, 'rethrow'>`):
466
475
 
467
476
  | Option | Type | Required | Purpose |
468
477
  |:-------|:-----|:--------:|:--------|
469
478
  | `operation` | `string` | Yes | Name logged with the error |
470
- | `context` | `ErrorContext` | No | Extra structured fields merged into the log record; `requestId` and `timestamp` receive special treatment |
479
+ | `context` | `ErrorContext` | No | Structured fields merged into the log record only — never the thrown error's client-visible `data`; `requestId` and `timestamp` receive special treatment |
471
480
  | `errorCode` | `JsonRpcErrorCode` | No | Code used if the caught error is not already an `McpError` |
472
481
  | `input` | `unknown` | No | Input value sanitized and logged alongside the error |
473
482
  | `critical` | `boolean` | No | Marks the error as critical in logs (default `false`) |
@@ -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.17"
7
+ version: "1.19"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -85,7 +85,7 @@ Why this family exists: different MCP clients forward different surfaces of a to
85
85
 
86
86
  Two consequences worth knowing when writing a `format()`:
87
87
 
88
- - **The string sentinel is alphanumeric so escaping does not break it.** `content[]` is markdown carrying upstream text you do not control, so escaping `_`, `*`, `` ` ``, `[`, `<` at the render boundary is correct — and it leaves an alphanumeric probe byte-identical. Markdown escaping, HTML escaping, and URL encoding all pass. You never need to carve an exception into your escape set to keep `lint:mcp` green.
88
+ - **The string sentinel is alphanumeric so escaping does not break it.** `content[]` is markdown carrying upstream text you do not control, so escaping at the render boundary is correct — and it leaves an alphanumeric probe byte-identical. Escape a character only where it would change rendering, per CommonMark/GFM rules: intraword `_` (`snake_case`), a `<` that cannot open a tag (`p<0.05`), and a `[` that cannot form a link all stay raw. Agents read `content[]` as text and copy spans out of it, so a blanket escape set turns into backslash noise in their output. Markdown escaping, HTML escaping, and URL encoding all pass. You never need to carve an exception into your escape set to keep `lint:mcp` green.
89
89
  - **Schema-dictated values must render as their own token.** A required `kind: z.enum(['full', 'outline'])` that `format()` never renders is not satisfied by the letters `full` appearing inside a longer word elsewhere in the output — `case_name_full`, `inactive`, `listing`. Render the field, or render its key name as a label.
90
90
 
91
91
  ### format-parity
@@ -201,6 +201,8 @@ Every field in `input`, `output`, `params`, or `args` needs a `.describe('...')`
201
201
  | `z.union([..., z.literal(X), ...])` literal option | **No** | No — outer union describe is sufficient |
202
202
  | A tool `input` root that is a `z.discriminatedUnion(...)` — its variant objects | Yes, their **fields** | No, not on the variant itself — it is a root, and roots carry no describe |
203
203
 
204
+ A self-referential schema — a Zod 4 getter that returns the schema itself (`get children() { return z.array(Node) }`) — is walked once. The walk tracks the schemas on its current path and stops when one re-enters, so a missing `.describe()` inside the recursive schema is reported at its first occurrence, not once per level. The guard is per path: a non-recursive schema reused at two sibling paths is reported at both.
205
+
204
206
  The asymmetry that catches agents: inside `z.union([z.string(), z.array(z.string())])`, the outer `z.string()` option **does** need a describe (unions walk non-literal options), but the `z.string()` inside the inner array does **not** (arrays don't walk primitive elements). If the linter didn't flag a path, don't add a describe there — the redundant describe ships to the JSON Schema as clutter.
205
207
 
206
208
  **Literal variants are exempt** because they carry no independent semantic content — they're structural markers. The canonical case is form-client blank tolerance, where a `z.literal('')` variant is threaded into a union alongside a validated string so empty submissions from MCP Inspector / web UIs round-trip without breaking schema-level validation:
@@ -373,9 +375,9 @@ Fires when emitted output contains `$defs` or `$ref`. Gemini rejects these (`400
373
375
 
374
376
  **Severity:** warning (only when `portability: 'strict'`)
375
377
 
376
- Fires when a tool's advertised `inputSchema` has a root-level `oneOf` — that is, when `input` is a `z.discriminatedUnion(...)`. The emitted shape is valid 2020-12, every branch is a typed object, and the bytes are identical on both MCP protocol revisions. What is unmeasured is vendor handling of a `oneOf` at the *parameter* root: a client that reads only `type` and `properties` would see a parameterless tool and drop the constraint silently rather than erroring. Opt-in, because for Anthropic clients the union is the better shape.
378
+ Fires when a tool's advertised `inputSchema` has a root-level `oneOf` — that is, when `input` is a `z.discriminatedUnion(...)`. The emitted shape is valid 2020-12, every branch is a typed object, and the bytes are identical on both MCP protocol revisions. Vendor handling of a `oneOf` at the *parameter* root varies: a client that reads only `type` and `properties` sees a parameterless tool and drops the constraint silently rather than erroring, and Claude clients — the Anthropic Messages API rejects a top-level `oneOf` — rewrite the root to its first branch's properties, hiding every other mode from the model. Opt-in for now; whether it warns by default is tracked in [#510](https://github.com/cyanheads/mcp-ts-core/issues/510).
377
379
 
378
- **Fix (only if you need the widest vendor reach):** flatten to a single `z.object()` with a discriminator field and optional per-mode fields, and validate the combination in the handler.
380
+ **Fix (for any tool that must work in Claude clients):** flatten to a single `z.object()` with a discriminator field and optional per-mode fields, and validate the combination in the handler.
379
381
 
380
382
  ### schema-dialect-tag
381
383
 
@@ -681,7 +683,7 @@ Heuristic source-text checks that scan `handler.toString()` for common error-han
681
683
 
682
684
  **Severity:** warning
683
685
 
684
- Fires when a handler contains `throw new Error(...)`. Plain `Error` doesn't carry a JSON-RPC code — the framework's auto-classifier degrades to `InternalError`, hiding the actual failure mode.
686
+ Fires when a handler contains `throw new Error(...)`, or `throw Error(...)` — the spelling Bun's transpiler prints for the same code, since it drops `new` from built-in error constructors. Plain `Error` doesn't carry a JSON-RPC code — the framework's auto-classifier degrades to `InternalError`, hiding the actual failure mode. Other built-ins (`TypeError`, `RangeError`) are not flagged in either spelling.
685
687
 
686
688
  Plain `Error` is acceptable for "don't care" cases where the specific code doesn't matter (per CLAUDE.md/AGENTS.md: "plain `Error` for don't-care cases"). This rule targets domain-specific failures that deserve a concrete code — upgrade those to factories or `ctx.fail`, and accept the warning for the rest.
687
689
 
@@ -713,7 +715,7 @@ throw notFound('Item missing');
713
715
 
714
716
  **Severity:** warning
715
717
 
716
- Fires when a `catch (e)` block throws a structured `McpError` (or factory) without passing `{ cause: e }`. Dropping the cause loses the original stack trace — observability platforms and `pino-pretty` rely on it to render error chains.
718
+ Fires when a `catch (e)` block throws a structured `McpError` (or factory) without passing `{ cause: e }`. Dropping the cause loses the original stack trace — observability platforms and `pino-pretty` rely on it to render error chains. When the catch binding is itself named `cause`, the `{ cause }` shorthand satisfies the rule — it is also how Bun's transpiler prints `{ cause: cause }`.
717
719
 
718
720
  **Fix:** thread the cause through the 4th `McpError` argument or factory options:
719
721
 
@@ -1003,6 +1005,8 @@ Fires when an enrichment key matches an `output` key. The effective output schem
1003
1005
 
1004
1006
  Advisory. Fires when a tool has **no** `enrichment` block but an `output` field whose name strongly signals agent-facing context (`notice`, `effectiveQuery`, `queryEcho`) rather than domain payload.
1005
1007
 
1008
+ **Exempt:** a `notice` in an `output` that also declares a `sections` array — the outline-on-overflow arm (`OUTLINE_VARIANT`, see the `techniques` skill). There the notice is the re-call instruction that replaces the document, main-body payload by design, and enrichment can only add to a payload, never replace it.
1009
+
1006
1010
  **Fix:** move the field into an `enrichment` block and populate it via `ctx.enrich(...)` — it reaches both client surfaces without a `format()` entry. Ignore if the field is genuinely domain data. Deliberately conservative — common domain fields like `totalCount` are not flagged.
1007
1011
 
1008
1012
  ### enrichment-trailer-render
@@ -1065,11 +1069,11 @@ Singularization covers only the bounded suffixes above (`ies` → `y`, `ses`/`xe
1065
1069
  A silently capped list leaves the agent unaware that results were cut off — it may treat a partial set as complete. Use `ctx.enrich.truncated({ shown, cap })` for the one-liner:
1066
1070
 
1067
1071
  ```ts
1068
- // In the enrichment block:
1072
+ // In the enrichment block — optional, since truncated() fires only on a capped page:
1069
1073
  enrichment: {
1070
- truncated: z.boolean().describe('True when the list was capped at the limit.'),
1071
- shown: z.number().describe('Number of items returned.'),
1072
- cap: z.number().describe('The limit applied.'),
1074
+ truncated: z.boolean().optional().describe('True when the list was capped at the limit.'),
1075
+ shown: z.number().optional().describe('Number of items returned.'),
1076
+ cap: z.number().optional().describe('The limit applied.'),
1073
1077
  },
1074
1078
 
1075
1079
  // In the handler:
@@ -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.12"
7
+ version: "1.14"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -13,7 +13,7 @@ metadata:
13
13
 
14
14
  The framework auto-instruments every tool, resource, prompt, storage, LLM, speech, and graph call — each gets its own span and the standard counters/histograms. HTTP server requests pick up spans from `HttpInstrumentation` (all Node.js HTTP traffic, skips `/healthz`) plus `httpInstrumentationMiddleware` from `@hono/otel` on the MCP HTTP endpoint when installed (optional Tier 3 peer — `bun add @hono/otel`). On Bun, `HttpInstrumentation` silently no-ops and `@hono/otel` is the only HTTP coverage. Auth checks and session lifecycle are tracked as **metrics only** — auth decorates the active HTTP span with attributes, sessions emit counters.
15
15
 
16
- `requestId`, `traceId`, and `tenantId` correlate automatically across spans, metrics, and logs. Pino logs get `trace_id`/`span_id` injected when a span is active.
16
+ `requestId`, `traceId`, and `tenantId` correlate automatically across spans, metrics, and logs. Framework log records carry `traceId`/`spanId` from the request context.
17
17
 
18
18
  A handler's `ctx.traceId` / `ctx.spanId` name the execution span it runs in — `tool_execution:<name>` or `resource_read:<name>` — not the enclosing HTTP request span. Under HTTP the trace ID is the request's, so handler logs join to the request; the span ID is the child execution's, so they join to that span's attributes and duration. On stdio, where no transport span exists, both are still populated from the execution span the framework opens. Both are `undefined` when telemetry is disabled: the non-recording span a disabled pipeline produces carries all-zero IDs, and the framework reports no correlation rather than IDs that correlate to nothing.
19
19
 
@@ -28,22 +28,28 @@ OTel is **off by default**. `OTEL_ENABLED=true` alone does nothing — you also
28
28
  | Env var | Default | Purpose |
29
29
  |:--------|:--------|:--------|
30
30
  | `OTEL_ENABLED` | `false` | Master switch. Must be `true` to start the SDK. |
31
- | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | — | OTLP/HTTP traces endpoint (e.g. `http://localhost:4318/v1/traces`). |
32
- | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | — | OTLP/HTTP metrics endpoint (e.g. `http://localhost:4318/v1/metrics`). |
31
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | — | OTLP/HTTP base URL (e.g. `http://localhost:4318`). Traces go to `<base>/v1/traces`, metrics to `<base>/v1/metrics`; a path prefix is kept. |
32
+ | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | — | OTLP/HTTP traces endpoint (e.g. `http://localhost:4318/v1/traces`). Overrides the base for traces; used as-is. |
33
+ | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | — | OTLP/HTTP metrics endpoint (e.g. `http://localhost:4318/v1/metrics`). Overrides the base for metrics; used as-is. |
34
+ | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | — | OTLP/HTTP logs endpoint (e.g. `http://localhost:4318/v1/logs`). Opt-in log export; used as-is and never derived from the base. |
33
35
  | `OTEL_SERVICE_NAME` | `createApp` `name` → `package.json` `name` | `service.name` resource attribute. Seeded from `createApp({ name })` when unset; an env value wins. |
34
36
  | `OTEL_SERVICE_VERSION` | `package.json` `version` | `service.version` resource attribute. |
35
37
  | `OTEL_TRACES_SAMPLER_ARG` | `1.0` | Trace sampling ratio (0–1) for `TraceIdRatioBasedSampler`. |
36
- | `OTEL_LOG_LEVEL` | `INFO` | OTel diagnostic logger level (`NONE`/`ERROR`/`WARN`/`INFO`/`DEBUG`/`VERBOSE`/`ALL`). |
38
+ | `OTEL_LOG_LEVEL` | `INFO` | OTel diagnostic logger level (`NONE`/`ERROR`/`WARN`/`INFO`/`DEBUG`/`VERBOSE`/`ALL`; `warning`/`err`/`information` accepted). Diag output goes to stderr at every level, never stdout. |
37
39
 
38
40
  Metrics push via `PeriodicExportingMetricReader` every **15 seconds**. Traces use `BatchSpanProcessor`.
39
41
 
42
+ Traces and metrics endpoints resolve per the [OTLP exporter spec](https://opentelemetry.io/docs/specs/otel/protocol/exporter/#endpoint-urls-for-otlphttp): the signal-specific variable as-is, else the base plus the signal path. A signal with no resolved endpoint exports nothing, and `NodeSDK`'s own `OTEL_METRICS_EXPORTER` / `OTEL_LOGS_EXPORTER` defaults are not consulted.
43
+
44
+ Log records export only when `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` is set. The base endpoint alone never turns it on, so a deployment exporting traces and metrics keeps its logs local until it opts in. When set, every record the framework logger writes — after the `MCP_LOG_LEVEL` filter and the rate limit, with the same field redaction as the pino output — is also sent through a `BatchLogRecordProcessor`, with its MCP level as the severity and the active span's trace context (a handler's `ctx.log` record joins its `tool_execution:*` span). `interactions.log` transcripts are never exported. Log export needs three more optional peers: `bun add @opentelemetry/sdk-logs @opentelemetry/exporter-logs-otlp-http @opentelemetry/api-logs`.
45
+
40
46
  ---
41
47
 
42
48
  ## Runtime support
43
49
 
44
50
  | Runtime | Behavior |
45
51
  |:--------|:---------|
46
- | **Node.js / Bun** | Full `NodeSDK`. Auto-instrumentations: HTTP server (Node http hooks; skips `/healthz`), Pino logs (`trace_id`/`span_id` injection). On the HTTP transport, when OTel is enabled and `@hono/otel` is installed, `httpInstrumentationMiddleware` is also wired onto the MCP endpoint — fills the gap on Bun, where the Node http auto-instrumentation silently no-ops. Manual spans, custom metrics, and OTLP export work on Bun regardless. |
52
+ | **Node.js / Bun** | Full `NodeSDK`. Auto-instrumentations: HTTP server (Node http hooks; skips `/healthz`), and Pino, which patches only a `pino` loaded after the SDK starts — never the framework logger's, imported first. On the HTTP transport, when OTel is enabled and `@hono/otel` is installed, `httpInstrumentationMiddleware` is also wired onto the MCP endpoint — fills the gap on Bun, where the Node http auto-instrumentation silently no-ops. Manual spans, custom metrics, and OTLP export work on Bun regardless. |
47
53
  | **Cloudflare Workers / V8 isolates** | `NodeSDK` is unavailable. SDK init no-ops silently. `createCounter`/`createHistogram`/`withSpan` calls still work via the global OTel API but produce no output unless you wire a Worker-compatible exporter and `ctx.waitUntil()` for flush. |
48
54
 
49
55
  Cloud platform detection auto-populates resource attributes:
@@ -61,6 +67,8 @@ Cloud platform detection auto-populates resource attributes:
61
67
 
62
68
  Spans batch and metrics push on a 15-second cycle, so a process that exits between cycles takes its telemetry with it. `ServerHandle.shutdown()` is the drain: it stops the transport, runs the `teardown` hook, then force-flushes traces and metrics through the OTLP exporters and closes the logger.
63
69
 
70
+ A failed flush is logged as a warning and the logger still closes, so the final log lines survive. The usual cause is an exporter that can't reach its collector and hits the 5 s OTel shutdown ceiling.
71
+
64
72
  | Trigger | Path | Exit |
65
73
  |:--------|:-----|:-----|
66
74
  | `SIGTERM` / `SIGINT` | `shutdown(signal)`, then an explicit exit | `0`, or `1` when the backstop fires |
@@ -107,7 +115,7 @@ Two consequences worth knowing when reading a dashboard:
107
115
  | `mcp.tool.duration` / `mcp.resource.duration` | The handler **plus** validation, formatting, and the enrichment merge — time to produce the result, not time spent in handler code. An expensive `format()` shows up here. |
108
116
  | `mcp.tool.output_bytes` / `mcp.resource.output_bytes` | The handler's returned domain value, not the assembled result. `content[]` re-renders the data the structured payload already carries, so measuring the assembly would double-count it. Nothing is recorded for a call that fails after the handler. |
109
117
 
110
- `mcp.tool.partial_success` and the `mcp.tool.batch.*` counts read the same domain value, so a batch envelope (`{ succeeded, failed }`) is still detected once the result has been assembled around it.
118
+ `mcp.tool.partial_success` and the `mcp.tool.batch.*` counts read the same domain value, so a batch envelope (`{ succeeded, failed }`) is still detected once the result has been assembled around it. For an `output` built with `partialResultSchema()`, the arrays are read under its `failedKey`/`succeededKey`, resolved once per definition from the output schema.
111
119
 
112
120
  Trace context propagates across boundaries via W3C `traceparent` headers. See `api-utils` → `telemetry/trace` for `withSpan`, `buildTraceparent`, `extractTraceparent`, `createContextWithParentTrace`, `injectCurrentContextInto`, `runInContext` signatures.
113
121
 
@@ -121,9 +129,10 @@ All custom metrics are namespaced `mcp.*` (or `process.*` / `http.client.*` wher
121
129
 
122
130
  | Metric | Type | Unit | Attributes |
123
131
  |:-------|:-----|:-----|:-----------|
124
- | `mcp.tool.calls` | counter | `{calls}` | `mcp.tool.name`, `mcp.tool.success` |
132
+ | `mcp.tool.calls` | counter | `{calls}` | `mcp.tool.name`, `mcp.tool.success`, `mcp.tool.outcome` (`ok`/`error`/`cancelled`) |
125
133
  | `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`) — see [Error category](#error-category) |
134
+ | `mcp.tool.errors` | counter | `{errors}` | `mcp.tool.name`, `mcp.tool.error_category` (`upstream`/`server`/`client`) — see [Error category](#error-category) — and `mcp.tool.outcome` (`error`/`cancelled`) |
135
+ | `mcp.tool.rejections` | counter | `{calls}` | `mcp.tool.name`, `mcp.tool.error_code`, `mcp.tool.error_category` — once per call rejected before the handler ran |
127
136
  | `mcp.tool.input_bytes` | histogram | `bytes` | `mcp.tool.name` |
128
137
  | `mcp.tool.output_bytes` | histogram | `bytes` | `mcp.tool.name` (success only; the handler's returned value) |
129
138
  | `mcp.tool.param.usage` | counter | `{uses}` | `mcp.tool.name`, `mcp.tool.param` (top-level keys supplied by caller) |
@@ -142,6 +151,8 @@ All custom metrics are namespaced `mcp.*` (or `process.*` / `http.client.*` wher
142
151
  | `mcp.prompt.message_count` | histogram | `{messages}` | `mcp.prompt.name` |
143
152
  | `mcp.requests.active` | up/down counter | `{requests}` | — (in-flight handler executions, all three types) |
144
153
 
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
+
145
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.
146
157
 
147
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.
@@ -194,9 +205,9 @@ Read together: `queue_depth` rising while `wait` climbs means the configured rat
194
205
 
195
206
  ### Error category
196
207
 
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.
208
+ `mcp.tool.error_category`, `mcp.prompt.error_category`, and `mcp.error.category` on `mcp.errors.classified` 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 JSON-RPC code the caller receives — for a thrown value that is not an `McpError`, the code the auto-classifier assigns, so `Error('Request timed out')` is `upstream` and a handler-thrown `ZodError` is `client` on every counter, and all three agree per failure. The span's and completion log's error code for such a value stays `UNHANDLED_ERROR` / `UNKNOWN_ERROR`. The 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
209
 
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.
210
+ A dashboard reading `error_category` alone therefore no longer needs to special-case one server's capacity limit as an upstream outage, and one grouping `mcp.errors.classified` by origin reads `mcp.error.category` rather than decoding the code with its own copy of the table — the code cannot see `data.reason`. `reason` itself is not on the metric — it is unbounded across a fleet, so it lives on the span and in the log.
200
211
 
201
212
  ### Declared error severity
202
213
 
@@ -211,7 +222,7 @@ The call still failed: the execution span keeps `SpanStatusCode.ERROR` and its r
211
222
 
212
223
  | Metric | Type | Unit | Attributes |
213
224
  |:-------|:-----|:-----|:-----------|
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 |
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 |
215
226
  | `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) |
216
227
  | `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) |
217
228
 
@@ -232,7 +243,7 @@ Auto-registered when `process.memoryUsage` / `process.uptime` / `perf_hooks` are
232
243
 
233
244
  ## Logs
234
245
 
235
- Pino logs are auto-instrumented by `@opentelemetry/instrumentation-pino`. When a span is active, `trace_id` and `span_id` are injected into the record. Combined with the framework logger's automatic `requestId`/`tenantId` correlation, every log line is searchable by trace.
246
+ 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).
236
247
 
237
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:
238
249
 
@@ -242,6 +253,20 @@ For domain logging inside handlers, use `ctx.log` (`debug`/`info`/`notice`/`warn
242
253
  | Resource | `Resource read finished.` | `durationMs`, `isSuccess`, `errorCode`, `outputBytes`, `uri`, `mimeType` |
243
254
  | Prompt | `Prompt generation finished.` (or `failed.`) | `durationMs`, `isSuccess`, `errorCode`, `inputBytes`, `outputBytes`, `messageCount` |
244
255
 
256
+ ### Failed-call payloads
257
+
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.
259
+
260
+ | Field | Content |
261
+ |:------|:--------|
262
+ | `toolInput` | The arguments as the caller sent them, before pre-validation drops or renames a key |
263
+ | `toolResult` | The `CallToolResult` the tool returned. On 2026-07-28 the SDK adds `resultType` and `_meta` serverInfo on the wire after the record is written |
264
+ | `toolInputTruncated` / `toolResultTruncated` | Whether that payload was cut at `LOG_TOOL_FAILURE_PAYLOAD_MAX_BYTES` (default `16384`) |
265
+
266
+ Each payload is redacted with `sanitization.sanitizeForLogging`, serialized, then cut on a UTF-8 character boundary, each on its own. They are strings, not objects, because the logger drops values nested deeper than four levels. Covered: `auth` refusals, argument rejections (`-32602`), handler throws, and output/enrichment contract failures. Nothing is written for a success, a `RequestCancelled`, or an `input_required` return, nor for resource and prompt failures.
267
+
268
+ The record goes wherever the error record goes: stderr, `combined.log`, and OTLP when `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` is set. On Workers, where no file sink exists, set the flag as a Worker binding. It passes the `MCP_LOG_LEVEL` filter and the rate limit like any record, and its message is constant per tool, so when one tool fails more than `MCP_LOG_RATE_LIMIT_THRESHOLD` times in a window, only the first payloads are kept. **Redaction matches key names only.** A secret inside a free-form value, such as a token pasted into a `query` or a connection string in an error message, is written as-is. Enable it only where the log store is trusted with caller data.
269
+
245
270
  ---
246
271
 
247
272
  ## Custom instrumentation