@cyanheads/mcp-ts-core 0.13.7 → 0.13.9

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 (274) hide show
  1. package/AGENTS.md +19 -15
  2. package/CLAUDE.md +19 -15
  3. package/README.md +4 -2
  4. package/changelog/0.13.x/0.13.8.md +101 -0
  5. package/changelog/0.13.x/0.13.9.md +113 -0
  6. package/dist/config/index.d.ts +9 -0
  7. package/dist/config/index.d.ts.map +1 -1
  8. package/dist/config/index.js +39 -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 +20 -6
  13. package/dist/core/app.js.map +1 -1
  14. package/dist/core/context.d.ts +25 -1
  15. package/dist/core/context.d.ts.map +1 -1
  16. package/dist/core/context.js +13 -3
  17. package/dist/core/context.js.map +1 -1
  18. package/dist/core/serverManifest.d.ts +6 -0
  19. package/dist/core/serverManifest.d.ts.map +1 -1
  20. package/dist/core/serverManifest.js +6 -0
  21. package/dist/core/serverManifest.js.map +1 -1
  22. package/dist/core/worker.d.ts +2 -0
  23. package/dist/core/worker.d.ts.map +1 -1
  24. package/dist/core/worker.js +2 -0
  25. package/dist/core/worker.js.map +1 -1
  26. package/dist/linter/rules/enrichment-rules.d.ts +3 -2
  27. package/dist/linter/rules/enrichment-rules.d.ts.map +1 -1
  28. package/dist/linter/rules/enrichment-rules.js +9 -2
  29. package/dist/linter/rules/enrichment-rules.js.map +1 -1
  30. package/dist/linter/rules/handler-body-rules.d.ts.map +1 -1
  31. package/dist/linter/rules/handler-body-rules.js +10 -4
  32. package/dist/linter/rules/handler-body-rules.js.map +1 -1
  33. package/dist/linter/rules/schema-rules.d.ts +5 -0
  34. package/dist/linter/rules/schema-rules.d.ts.map +1 -1
  35. package/dist/linter/rules/schema-rules.js +44 -17
  36. package/dist/linter/rules/schema-rules.js.map +1 -1
  37. package/dist/linter/rules/tool-rules.d.ts +2 -1
  38. package/dist/linter/rules/tool-rules.d.ts.map +1 -1
  39. package/dist/linter/rules/tool-rules.js +36 -1
  40. package/dist/linter/rules/tool-rules.js.map +1 -1
  41. package/dist/mcp-server/inputRequired.d.ts +14 -5
  42. package/dist/mcp-server/inputRequired.d.ts.map +1 -1
  43. package/dist/mcp-server/inputRequired.js +15 -8
  44. package/dist/mcp-server/inputRequired.js.map +1 -1
  45. package/dist/mcp-server/outputContract.d.ts +33 -0
  46. package/dist/mcp-server/outputContract.d.ts.map +1 -0
  47. package/dist/mcp-server/outputContract.js +43 -0
  48. package/dist/mcp-server/outputContract.js.map +1 -0
  49. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
  50. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +10 -2
  51. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
  52. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts +163 -40
  53. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts.map +1 -1
  54. package/dist/mcp-server/tools/utils/inputPrevalidation.js +330 -114
  55. package/dist/mcp-server/tools/utils/inputPrevalidation.js.map +1 -1
  56. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +39 -15
  57. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  58. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +361 -93
  59. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  60. package/dist/mcp-server/transports/auth/lib/authUtils.js +4 -1
  61. package/dist/mcp-server/transports/auth/lib/authUtils.js.map +1 -1
  62. package/dist/mcp-server/transports/auth/strategies/jwtStrategy.d.ts.map +1 -1
  63. package/dist/mcp-server/transports/auth/strategies/jwtStrategy.js +1 -1
  64. package/dist/mcp-server/transports/auth/strategies/jwtStrategy.js.map +1 -1
  65. package/dist/mcp-server/transports/auth/strategies/oauthStrategy.d.ts.map +1 -1
  66. package/dist/mcp-server/transports/auth/strategies/oauthStrategy.js +2 -5
  67. package/dist/mcp-server/transports/auth/strategies/oauthStrategy.js.map +1 -1
  68. package/dist/mcp-server/transports/http/httpTransport.d.ts.map +1 -1
  69. package/dist/mcp-server/transports/http/httpTransport.js +65 -9
  70. package/dist/mcp-server/transports/http/httpTransport.js.map +1 -1
  71. package/dist/mcp-server/transports/http/sessionStore.d.ts.map +1 -1
  72. package/dist/mcp-server/transports/http/sessionStore.js +2 -2
  73. package/dist/mcp-server/transports/http/sessionStore.js.map +1 -1
  74. package/dist/mcp-server/transports/stdio/stdioTransport.d.ts +9 -5
  75. package/dist/mcp-server/transports/stdio/stdioTransport.d.ts.map +1 -1
  76. package/dist/mcp-server/transports/stdio/stdioTransport.js +9 -5
  77. package/dist/mcp-server/transports/stdio/stdioTransport.js.map +1 -1
  78. package/dist/services/canvas/core/CanvasRegistry.d.ts +6 -2
  79. package/dist/services/canvas/core/CanvasRegistry.d.ts.map +1 -1
  80. package/dist/services/canvas/core/CanvasRegistry.js +8 -4
  81. package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
  82. package/dist/services/canvas/core/DataCanvas.d.ts.map +1 -1
  83. package/dist/services/canvas/core/DataCanvas.js +7 -5
  84. package/dist/services/canvas/core/DataCanvas.js.map +1 -1
  85. package/dist/services/canvas/core/canvasFactory.d.ts.map +1 -1
  86. package/dist/services/canvas/core/canvasFactory.js +2 -2
  87. package/dist/services/canvas/core/canvasFactory.js.map +1 -1
  88. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts +82 -18
  89. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts.map +1 -1
  90. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js +645 -344
  91. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js.map +1 -1
  92. package/dist/services/canvas/providers/duckdb/exportWriter.d.ts +11 -7
  93. package/dist/services/canvas/providers/duckdb/exportWriter.d.ts.map +1 -1
  94. package/dist/services/canvas/providers/duckdb/exportWriter.js +19 -16
  95. package/dist/services/canvas/providers/duckdb/exportWriter.js.map +1 -1
  96. package/dist/services/llm/providers/openrouter.provider.js +1 -1
  97. package/dist/services/llm/providers/openrouter.provider.js.map +1 -1
  98. package/dist/services/mirror/core/defineMirror.d.ts +1 -0
  99. package/dist/services/mirror/core/defineMirror.d.ts.map +1 -1
  100. package/dist/services/mirror/core/defineMirror.js +1 -0
  101. package/dist/services/mirror/core/defineMirror.js.map +1 -1
  102. package/dist/services/speech/providers/elevenlabs.provider.js +3 -3
  103. package/dist/services/speech/providers/elevenlabs.provider.js.map +1 -1
  104. package/dist/services/speech/providers/whisper.provider.d.ts.map +1 -1
  105. package/dist/services/speech/providers/whisper.provider.js +5 -5
  106. package/dist/services/speech/providers/whisper.provider.js.map +1 -1
  107. package/dist/storage/core/StorageService.d.ts.map +1 -1
  108. package/dist/storage/core/StorageService.js +3 -6
  109. package/dist/storage/core/StorageService.js.map +1 -1
  110. package/dist/storage/core/storageFactory.d.ts.map +1 -1
  111. package/dist/storage/core/storageFactory.js +12 -15
  112. package/dist/storage/core/storageFactory.js.map +1 -1
  113. package/dist/storage/core/storageValidation.d.ts +13 -13
  114. package/dist/storage/core/storageValidation.d.ts.map +1 -1
  115. package/dist/storage/core/storageValidation.js +49 -125
  116. package/dist/storage/core/storageValidation.js.map +1 -1
  117. package/dist/storage/providers/cloudflare/d1Provider.d.ts.map +1 -1
  118. package/dist/storage/providers/cloudflare/d1Provider.js +5 -3
  119. package/dist/storage/providers/cloudflare/d1Provider.js.map +1 -1
  120. package/dist/storage/providers/cloudflare/kvProvider.d.ts.map +1 -1
  121. package/dist/storage/providers/cloudflare/kvProvider.js +1 -1
  122. package/dist/storage/providers/cloudflare/kvProvider.js.map +1 -1
  123. package/dist/storage/providers/cloudflare/r2Provider.d.ts.map +1 -1
  124. package/dist/storage/providers/cloudflare/r2Provider.js +3 -3
  125. package/dist/storage/providers/cloudflare/r2Provider.js.map +1 -1
  126. package/dist/storage/providers/fileSystem/fileSystemProvider.js +4 -4
  127. package/dist/storage/providers/fileSystem/fileSystemProvider.js.map +1 -1
  128. package/dist/storage/providers/inMemory/inMemoryProvider.d.ts +1 -1
  129. package/dist/storage/providers/inMemory/inMemoryProvider.d.ts.map +1 -1
  130. package/dist/storage/providers/inMemory/inMemoryProvider.js +6 -5
  131. package/dist/storage/providers/inMemory/inMemoryProvider.js.map +1 -1
  132. package/dist/testing/fuzz.d.ts.map +1 -1
  133. package/dist/testing/fuzz.js +7 -1
  134. package/dist/testing/fuzz.js.map +1 -1
  135. package/dist/testing/index.d.ts +15 -2
  136. package/dist/testing/index.d.ts.map +1 -1
  137. package/dist/testing/index.js +51 -6
  138. package/dist/testing/index.js.map +1 -1
  139. package/dist/types-global/errors.d.ts +7 -4
  140. package/dist/types-global/errors.d.ts.map +1 -1
  141. package/dist/types-global/errors.js.map +1 -1
  142. package/dist/utils/formatting/codeSpan.d.ts +27 -0
  143. package/dist/utils/formatting/codeSpan.d.ts.map +1 -0
  144. package/dist/utils/formatting/codeSpan.js +42 -0
  145. package/dist/utils/formatting/codeSpan.js.map +1 -0
  146. package/dist/utils/formatting/diffFormatter.d.ts.map +1 -1
  147. package/dist/utils/formatting/diffFormatter.js +7 -15
  148. package/dist/utils/formatting/diffFormatter.js.map +1 -1
  149. package/dist/utils/formatting/markdownBuilder.d.ts +12 -5
  150. package/dist/utils/formatting/markdownBuilder.d.ts.map +1 -1
  151. package/dist/utils/formatting/markdownBuilder.js +14 -2
  152. package/dist/utils/formatting/markdownBuilder.js.map +1 -1
  153. package/dist/utils/formatting/tableFormatter.d.ts.map +1 -1
  154. package/dist/utils/formatting/tableFormatter.js +5 -9
  155. package/dist/utils/formatting/tableFormatter.js.map +1 -1
  156. package/dist/utils/formatting/treeFormatter.d.ts.map +1 -1
  157. package/dist/utils/formatting/treeFormatter.js +5 -9
  158. package/dist/utils/formatting/treeFormatter.js.map +1 -1
  159. package/dist/utils/index.d.ts +1 -1
  160. package/dist/utils/index.d.ts.map +1 -1
  161. package/dist/utils/index.js.map +1 -1
  162. package/dist/utils/internal/error-handler/errorHandler.d.ts +17 -10
  163. package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
  164. package/dist/utils/internal/error-handler/errorHandler.js +47 -26
  165. package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
  166. package/dist/utils/internal/error-handler/mappings.d.ts +17 -1
  167. package/dist/utils/internal/error-handler/mappings.d.ts.map +1 -1
  168. package/dist/utils/internal/error-handler/mappings.js +22 -1
  169. package/dist/utils/internal/error-handler/mappings.js.map +1 -1
  170. package/dist/utils/internal/error-handler/types.d.ts +2 -0
  171. package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
  172. package/dist/utils/internal/logger.d.ts +75 -3
  173. package/dist/utils/internal/logger.d.ts.map +1 -1
  174. package/dist/utils/internal/logger.js +181 -52
  175. package/dist/utils/internal/logger.js.map +1 -1
  176. package/dist/utils/internal/performance.d.ts +11 -0
  177. package/dist/utils/internal/performance.d.ts.map +1 -1
  178. package/dist/utils/internal/performance.js +46 -12
  179. package/dist/utils/internal/performance.js.map +1 -1
  180. package/dist/utils/network/fetchWithTimeout.d.ts +11 -5
  181. package/dist/utils/network/fetchWithTimeout.d.ts.map +1 -1
  182. package/dist/utils/network/fetchWithTimeout.js +50 -23
  183. package/dist/utils/network/fetchWithTimeout.js.map +1 -1
  184. package/dist/utils/network/pacer.d.ts +38 -5
  185. package/dist/utils/network/pacer.d.ts.map +1 -1
  186. package/dist/utils/network/pacer.js +87 -25
  187. package/dist/utils/network/pacer.js.map +1 -1
  188. package/dist/utils/network/retry.d.ts +16 -8
  189. package/dist/utils/network/retry.d.ts.map +1 -1
  190. package/dist/utils/network/retry.js +19 -8
  191. package/dist/utils/network/retry.js.map +1 -1
  192. package/dist/utils/overflow/outlineOnOverflow.d.ts +18 -2
  193. package/dist/utils/overflow/outlineOnOverflow.d.ts.map +1 -1
  194. package/dist/utils/overflow/outlineOnOverflow.js +28 -3
  195. package/dist/utils/overflow/outlineOnOverflow.js.map +1 -1
  196. package/dist/utils/pagination/pagination.d.ts +3 -1
  197. package/dist/utils/pagination/pagination.d.ts.map +1 -1
  198. package/dist/utils/pagination/pagination.js +10 -2
  199. package/dist/utils/pagination/pagination.js.map +1 -1
  200. package/dist/utils/parsing/csvParser.d.ts.map +1 -1
  201. package/dist/utils/parsing/csvParser.js +4 -2
  202. package/dist/utils/parsing/csvParser.js.map +1 -1
  203. package/dist/utils/parsing/htmlExtractor.js +1 -1
  204. package/dist/utils/parsing/htmlExtractor.js.map +1 -1
  205. package/dist/utils/parsing/jsonParser.d.ts.map +1 -1
  206. package/dist/utils/parsing/jsonParser.js +3 -1
  207. package/dist/utils/parsing/jsonParser.js.map +1 -1
  208. package/dist/utils/parsing/xmlParser.d.ts.map +1 -1
  209. package/dist/utils/parsing/xmlParser.js +3 -1
  210. package/dist/utils/parsing/xmlParser.js.map +1 -1
  211. package/dist/utils/parsing/yamlParser.d.ts.map +1 -1
  212. package/dist/utils/parsing/yamlParser.js +3 -1
  213. package/dist/utils/parsing/yamlParser.js.map +1 -1
  214. package/dist/utils/security/idGenerator.d.ts.map +1 -1
  215. package/dist/utils/security/idGenerator.js +20 -4
  216. package/dist/utils/security/idGenerator.js.map +1 -1
  217. package/dist/utils/security/sanitization.d.ts +31 -0
  218. package/dist/utils/security/sanitization.d.ts.map +1 -1
  219. package/dist/utils/security/sanitization.js +98 -11
  220. package/dist/utils/security/sanitization.js.map +1 -1
  221. package/dist/utils/telemetry/attributes.d.ts +21 -2
  222. package/dist/utils/telemetry/attributes.d.ts.map +1 -1
  223. package/dist/utils/telemetry/attributes.js +21 -2
  224. package/dist/utils/telemetry/attributes.js.map +1 -1
  225. package/dist/utils/telemetry/instrumentation.d.ts +9 -3
  226. package/dist/utils/telemetry/instrumentation.d.ts.map +1 -1
  227. package/dist/utils/telemetry/instrumentation.js +85 -13
  228. package/dist/utils/telemetry/instrumentation.js.map +1 -1
  229. package/framework-skills/add-app-tool/SKILL.md +3 -3
  230. package/framework-skills/add-export/SKILL.md +5 -16
  231. package/framework-skills/add-prompt/SKILL.md +7 -3
  232. package/framework-skills/add-resource/SKILL.md +7 -5
  233. package/framework-skills/add-tool/SKILL.md +12 -10
  234. package/framework-skills/api-auth/SKILL.md +4 -2
  235. package/framework-skills/api-canvas/SKILL.md +19 -10
  236. package/framework-skills/api-config/SKILL.md +9 -6
  237. package/framework-skills/api-context/SKILL.md +16 -5
  238. package/framework-skills/api-errors/SKILL.md +23 -17
  239. package/framework-skills/api-linter/SKILL.md +32 -9
  240. package/framework-skills/api-mirror/SKILL.md +2 -1
  241. package/framework-skills/api-telemetry/SKILL.md +34 -14
  242. package/framework-skills/api-testing/SKILL.md +5 -3
  243. package/framework-skills/api-utils/SKILL.md +10 -10
  244. package/framework-skills/api-utils/references/formatting.md +1 -1
  245. package/framework-skills/api-utils/references/parsing.md +2 -2
  246. package/framework-skills/api-utils/references/security.md +6 -4
  247. package/framework-skills/design-mcp-server/SKILL.md +2 -2
  248. package/framework-skills/field-test/SKILL.md +4 -4
  249. package/framework-skills/git-wrapup/SKILL.md +12 -7
  250. package/framework-skills/maintenance/SKILL.md +2 -2
  251. package/framework-skills/orchestrations/SKILL.md +7 -6
  252. package/framework-skills/orchestrations/workflows/field-test-fix.md +9 -19
  253. package/framework-skills/orchestrations/workflows/fix-wrapup-release.md +7 -7
  254. package/framework-skills/orchestrations/workflows/greenfield-build.md +8 -5
  255. package/framework-skills/orchestrations/workflows/maintenance-release.md +8 -8
  256. package/framework-skills/polish-docs-meta/SKILL.md +4 -4
  257. package/framework-skills/polish-docs-meta/references/readme.md +1 -0
  258. package/framework-skills/release-and-publish/SKILL.md +7 -5
  259. package/framework-skills/release-pr-review/SKILL.md +37 -23
  260. package/framework-skills/report-issue-framework/SKILL.md +7 -5
  261. package/framework-skills/report-issue-local/SKILL.md +8 -6
  262. package/framework-skills/security-pass/SKILL.md +8 -8
  263. package/framework-skills/techniques/SKILL.md +1 -1
  264. package/framework-skills/techniques/references/outline-on-overflow.md +12 -7
  265. package/package.json +20 -5
  266. package/scripts/check-skill-versions.ts +103 -22
  267. package/scripts/devcheck.ts +11 -9
  268. package/scripts/lint-mcp.ts +87 -27
  269. package/scripts/lint-packaging.ts +99 -1
  270. package/scripts/release-github.ts +117 -5
  271. package/templates/.env.example +4 -0
  272. package/templates/Dockerfile +26 -6
  273. package/templates/_.mcpbignore +2 -0
  274. package/templates/package.json +1 -0
@@ -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.13"
7
+ version: "1.15"
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
 
@@ -31,14 +31,17 @@ OTel is **off by default**. `OTEL_ENABLED=true` alone does nothing — you also
31
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
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
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. |
34
35
  | `OTEL_SERVICE_NAME` | `createApp` `name` → `package.json` `name` | `service.name` resource attribute. Seeded from `createApp({ name })` when unset; an env value wins. |
35
36
  | `OTEL_SERVICE_VERSION` | `package.json` `version` | `service.version` resource attribute. |
36
37
  | `OTEL_TRACES_SAMPLER_ARG` | `1.0` | Trace sampling ratio (0–1) for `TraceIdRatioBasedSampler`. |
37
- | `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. |
38
39
 
39
40
  Metrics push via `PeriodicExportingMetricReader` every **15 seconds**. Traces use `BatchSpanProcessor`.
40
41
 
41
- 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. Those two exporters are the only export path. A signal with no resolved endpoint exports nothing, OTel log records are never exported, and `NodeSDK`'s own `OTEL_METRICS_EXPORTER` / `OTEL_LOGS_EXPORTER` defaults are not consulted.
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`.
42
45
 
43
46
  ---
44
47
 
@@ -46,7 +49,7 @@ Endpoints resolve per the [OTLP exporter spec](https://opentelemetry.io/docs/spe
46
49
 
47
50
  | Runtime | Behavior |
48
51
  |:--------|:---------|
49
- | **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. |
50
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. |
51
54
 
52
55
  Cloud platform detection auto-populates resource attributes:
@@ -70,7 +73,7 @@ A failed flush is logged as a warning and the logger still closes, so the final
70
73
  |:--------|:-----|:-----|
71
74
  | `SIGTERM` / `SIGINT` | `shutdown(signal)`, then an explicit exit | `0`, or `1` when the backstop fires |
72
75
  | `uncaughtException` / `unhandledRejection` | `shutdown(signal)`, then an explicit exit | `1` |
73
- | 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 |
74
77
  | a second signal during shutdown | none — the handlers are already detached | the OS default (`143` / `130`) |
75
78
  | `ServerHandle.shutdown()` called directly | the same drain | none — exit-free by contract |
76
79
 
@@ -126,15 +129,16 @@ All custom metrics are namespaced `mcp.*` (or `process.*` / `http.client.*` wher
126
129
 
127
130
  | Metric | Type | Unit | Attributes |
128
131
  |:-------|:-----|:-----|:-----------|
129
- | `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`) |
130
133
  | `mcp.tool.duration` | histogram | `ms` | `mcp.tool.name`, `mcp.tool.success` |
131
- | `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 |
132
136
  | `mcp.tool.input_bytes` | histogram | `bytes` | `mcp.tool.name` |
133
137
  | `mcp.tool.output_bytes` | histogram | `bytes` | `mcp.tool.name` (success only; the handler's returned value) |
134
138
  | `mcp.tool.param.usage` | counter | `{uses}` | `mcp.tool.name`, `mcp.tool.param` (top-level keys supplied by caller) |
135
139
  | `mcp.input.ignored_key` | counter | `{keys}` | `mcp.tool.name`, `mcp.input.ignore_rule` (the ignore-list entry that matched, or `underscore_prefix`) |
136
140
  | `mcp.input.aliased` | counter | `{keys}` | `mcp.tool.name`, `mcp.input.target` (the declared key), `mcp.input.alias_kind` (`declared`/`case_style`) |
137
- | `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`) |
138
142
  | `mcp.resource.reads` | counter | `{reads}` | `mcp.resource.name`, `mcp.resource.success` |
139
143
  | `mcp.resource.duration` | histogram | `ms` | `mcp.resource.name`, `mcp.resource.success` |
140
144
  | `mcp.resource.errors` | counter | `{errors}` | `mcp.resource.name` |
@@ -147,7 +151,9 @@ All custom metrics are namespaced `mcp.*` (or `process.*` / `http.client.*` wher
147
151
  | `mcp.prompt.message_count` | histogram | `{messages}` | `mcp.prompt.name` |
148
152
  | `mcp.requests.active` | up/down counter | `{requests}` | — (in-flight handler executions, all three types) |
149
153
 
150
- 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.
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
+
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.
151
157
 
152
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.
153
159
 
@@ -199,9 +205,9 @@ Read together: `queue_depth` rising while `wait` climbs means the configured rat
199
205
 
200
206
  ### Error category
201
207
 
202
- `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.
203
209
 
204
- 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.
205
211
 
206
212
  ### Declared error severity
207
213
 
@@ -216,7 +222,7 @@ The call still failed: the execution span keeps `SpanStatusCode.ERROR` and its r
216
222
 
217
223
  | Metric | Type | Unit | Attributes |
218
224
  |:-------|:-----|:-----|:-----------|
219
- | `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 |
220
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) |
221
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) |
222
228
 
@@ -237,7 +243,7 @@ Auto-registered when `process.memoryUsage` / `process.uptime` / `perf_hooks` are
237
243
 
238
244
  ## Logs
239
245
 
240
- 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).
241
247
 
242
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:
243
249
 
@@ -247,6 +253,20 @@ For domain logging inside handlers, use `ctx.log` (`debug`/`info`/`notice`/`warn
247
253
  | Resource | `Resource read finished.` | `durationMs`, `isSuccess`, `errorCode`, `outputBytes`, `uri`, `mimeType` |
248
254
  | Prompt | `Prompt generation finished.` (or `failed.`) | `durationMs`, `isSuccess`, `errorCode`, `inputBytes`, `outputBytes`, `messageCount` |
249
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
+
250
270
  ---
251
271
 
252
272
  ## Custom instrumentation
@@ -4,7 +4,7 @@ description: >
4
4
  Testing patterns for MCP tool/resource handlers using `createMockContext` and Vitest. Covers mock context options, handler testing, McpError assertions, format testing, Vitest config setup, and test isolation conventions.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.11"
7
+ version: "1.12"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -99,7 +99,7 @@ try {
99
99
  }
100
100
  ```
101
101
 
102
- Routes match in registration order. `match` accepts an exact URL, `RegExp`, or request predicate; `respond` accepts a clonable `Response` or response factory. Set `once: true` for one-shot behavior. Unmatched requests throw unless `onUnhandled` is provided.
102
+ Routes match in registration order. `match` accepts an exact URL, `RegExp`, or request predicate; `respond` accepts a static `Response` or a response factory. A static response's body is read once, on the route's first match, and every call is served a fresh `Response` over those bytes with the same `status`, `statusText`, and headers — so a consumer that cancels the body, or an error-body reader like `httpErrorFromResponse` that stops past its cap, settles on Node as on Bun. Set `once: true` for one-shot behavior. Unmatched requests throw unless `onUnhandled` is provided.
103
103
 
104
104
  **A request predicate routes on the URL's origin, never a prefix.** `req.url.startsWith(BASE_URL)` also matches a lookalike host (`https://api.example.test.evil.com/...`), which CodeQL reports as high-severity incomplete URL substring sanitization — it scans test files as readily as `src/`, so a suite that is green locally still fails the security check on a pull request. Parse the URL and compare origins, matching the path separately:
105
105
 
@@ -133,7 +133,9 @@ toolContractSuite(searchTool, {
133
133
 
134
134
  Use `runToolContract(definition, input, { context })` from `/testing` when a custom test runner or an imperative assertion is a better fit. It intentionally skips transport auth and telemetry; those belong in transport/integration tests.
135
135
 
136
- Arguments that fail the `input` schema are rejected the way the production handler factory rejects them: `InvalidParams` (`-32602`), with a message naming the tool and every failing field. That is the code a client sees on the wire, so assert it — not `ValidationError` (`-32007`), which stays the classification for a `ZodError` a handler throws itself and for an output-schema rejection.
136
+ Arguments that fail the `input` schema are rejected the way the production handler factory rejects them: `InvalidParams` (`-32602`), with a message naming the tool and every failing field. That is the code a client sees on the wire, so assert it — not `ValidationError` (`-32007`), which stays the classification for a `ZodError` a handler throws itself. A result that breaks the tool's own `output` or `enrichment` schema is the definition's bug, so it returns `InternalError` (`-32603`) with a message naming that contract, exactly as in production.
137
+
138
+ Cancellation settles as it does in production. Pass `context: { signal }` and abort it: once the signal has fired, whatever the handler — or the output validation, `format()`, and enrichment after it — throws comes back as `RequestCancelled` (`-32011`), whether that is the signal's `AbortError`, its reason string, a `withRetry` backoff that stopped, or an `McpError` of the handler's own. A throw while the signal is still live keeps its own classification, and argument parsing stays outside the settle, so schema-invalid arguments on an aborted signal still return `InvalidParams`. A `toolContractSuite` error case with an aborted `context.signal` asserts `code: JsonRpcErrorCode.RequestCancelled` the same way.
137
139
 
138
140
  ---
139
141
 
@@ -4,7 +4,7 @@ description: >
4
4
  API reference for all utilities exported from `@cyanheads/mcp-ts-core/utils`. Use when looking up utility method signatures, options, peer dependencies, or usage patterns.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.12"
7
+ version: "2.14"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -31,13 +31,13 @@ Utility exports from `@cyanheads/mcp-ts-core/utils`. Utilities with complex APIs
31
31
 
32
32
  | Export | API | Notes |
33
33
  |:-------|:----|:------|
34
- | `fetchWithTimeout` | `(url, timeoutMs, context, options?: FetchWithTimeoutOptions) -> Promise<Response>` | Wraps `fetch` with `AbortController` timeout. `timeoutMs` bounds the **whole exchange**: on a 2xx carrying a body the returned `Response` is a passthrough wrapper that keeps the deadline armed until the body closes, errors, or is cancelled, so a stalled stream rejects the caller's `.text()`/`.json()` with the same `Timeout` error the header phase raises. `status`, `statusText`, `headers`, `url`, `redirected`, and `type` carry across the wrapper; the original body is locked by it, and bodyless/null-body responses (HEAD, 204/205/304) come back untouched. `FetchWithTimeoutOptions` extends `RequestInit` (minus `signal`) and adds `rejectPrivateIPs?: boolean`, `expectedStatuses?: number[]` (listed non-2xx statuses logged at `debug` not `error`, still thrown), `errorBodyLimit?: number` (bytes of a non-2xx body kept, default `500`), `errorHeaders?: string[]` (response headers copied onto `error.data.headers` on a non-2xx — same selector as `httpErrorFromResponse` below; `location` is selectable under `redirect: 'manual'` but does **not** compose with `rejectPrivateIPs`, whose per-hop branch consumes the 3xx before the throw path sees it), and `signal?: AbortSignal` (external cancellation — an abort on it throws `RequestCancelled` (-32011), logged at `info` and outside `withRetry`'s transient set, since the caller is gone and no retry can reach them). On a non-2xx, `error.data` carries `status`/`body` plus the legacy `statusCode`/`responseBody` aliases (identical values; consolidating in a future major); a body over `errorBodyLimit` is captured from both ends — 40% head, 60% tail, joined by `…[N bytes elided]…` — so a diagnostic behind a boilerplate preamble survives the cap, while a body still streaming at the 16 KiB scan ceiling stays head-only with a trailing `…`. SSRF guard (best-effort, not hard isolation): blocks RFC 1918, loopback, link-local, CGNAT, cloud metadata. DNS validation on Node, Bun, and Cloudflare Workers under `nodejs_compat`; hostname-only fallback otherwise. **Both resolvers are queried** — `resolve4`/`resolve6` (c-ares) and `lookup` (the system resolver, which is what reads `/etc/hosts`, split DNS, and NSS modules) — and a non-global answer from either rejects. Runtimes differ in which resolver the connection uses (Bun 1.4 moved `net.connect()` on Linux to `getaddrinfo` while leaving `dns.resolve*()` on c-ares), so checking one alone leaves a name the other can see unguarded; each probe settles independently, so a resolver absent from the runtime is skipped rather than fatal. Manual redirect following (max 5) with per-hop SSRF check. **DNS rebinding / TOCTOU gap** — the validation lookup and `fetch`'s own resolution are independent; pair with egress controls or a DNS-pinning fetch proxy for strong isolation. **Error/log redaction:** URLs written into thrown errors and log lines are reduced to `origin + pathname` — the query string (where API keys commonly ride: `?api-key=…`, `?api_key=…`) never reaches the client or the logs. The actual request still uses the full URL. |
34
+ | `fetchWithTimeout` | `(url, timeoutMs, context, options?: FetchWithTimeoutOptions) -> Promise<Response>` | Wraps `fetch` with `AbortController` timeout. `timeoutMs` bounds the **whole exchange**: on a 2xx carrying a body the returned `Response` is a passthrough wrapper that keeps the deadline armed until the body closes, errors, or is cancelled, so a stalled stream rejects the caller's `.text()`/`.json()` with the same `Timeout` error the header phase raises. `status`, `statusText`, `headers`, `url`, `redirected`, and `type` carry across the wrapper; the original body is locked by it, and bodyless/null-body responses (HEAD, 204/205/304) come back untouched. `FetchWithTimeoutOptions` extends `RequestInit` (minus `signal`) and adds `rejectPrivateIPs?: boolean`, `expectedStatuses?: number[]` (listed non-2xx statuses logged at `debug` not `error`, still thrown), `errorBodyLimit?: number` (bytes of a non-2xx body kept, default `500`), `errorHeaders?: string[]` (response headers copied onto `error.data.headers` on a non-2xx — same selector as `httpErrorFromResponse` below; `location` is selectable under `redirect: 'manual'` but does **not** compose with `rejectPrivateIPs`, whose per-hop branch consumes the 3xx before the throw path sees it), and `signal?: AbortSignal` (external cancellation — an abort on it throws `RequestCancelled` (-32011), logged at `info` and outside `withRetry`'s transient set, since the caller is gone and no retry can reach them; an abort whose reason is a `TimeoutError` — `AbortSignal.timeout()`, or an `AbortSignal.any` whose timeout member fired — is a deadline instead, and throws `Timeout` (-32004) with `data.errorSource: 'FetchSignalTimeout'`, distinct from the helper's own `timeoutMs` expiry, `'FetchTimeout'`, and likewise outside `withRetry`'s default transient set, since every retry would reuse the fired signal). Validation rejections carry `data.reason` and a `recovery.hint`: `invalid_url` (not an absolute `http:`/`https:` URL, including a redirect target), `private_address_blocked` (the SSRF guard refused the host by name, literal IP, or DNS answer), `too_many_redirects` (past the 5-hop cap, with `data.maxRedirects`). On a non-2xx, `error.data` carries `status`/`body` plus the legacy `statusCode`/`responseBody` aliases (identical values; consolidating in a future major); a body over `errorBodyLimit` is captured from both ends — 40% head, 60% tail, joined by `…[N bytes elided]…` — so a diagnostic behind a boilerplate preamble survives the cap, while a body still streaming at the 16 KiB scan ceiling stays head-only with a trailing `…`. SSRF guard (best-effort, not hard isolation): blocks RFC 1918, loopback, link-local, CGNAT, cloud metadata. DNS validation on Node, Bun, and Cloudflare Workers under `nodejs_compat`; hostname-only fallback otherwise. **Both resolvers are queried** — `resolve4`/`resolve6` (c-ares) and `lookup` (the system resolver, which is what reads `/etc/hosts`, split DNS, and NSS modules) — and a non-global answer from either rejects. Runtimes differ in which resolver the connection uses (Bun 1.4 moved `net.connect()` on Linux to `getaddrinfo` while leaving `dns.resolve*()` on c-ares), so checking one alone leaves a name the other can see unguarded; each probe settles independently, so a resolver absent from the runtime is skipped rather than fatal. Manual redirect following (max 5) with per-hop SSRF check. **DNS rebinding / TOCTOU gap** — the validation lookup and `fetch`'s own resolution are independent; pair with egress controls or a DNS-pinning fetch proxy for strong isolation. **Error/log redaction:** URLs written into thrown errors and log lines are reduced to `origin + pathname` — the query string (where API keys commonly ride: `?api-key=…`, `?api_key=…`) never reaches the client or the logs. The actual request still uses the full URL. |
35
35
  | `withRetry` | `<T>(fn: (attempt: RetryAttempt) => Promise<T>, options?: RetryOptions) -> Promise<T>` | Executes `fn` with exponential backoff. Retries on transient errors (`ServiceUnavailable`, `Timeout`, `RateLimited`); non-transient errors fail immediately. Honors an upstream `Retry-After` on `data.retryAfter` (delta-seconds or HTTP-date) over exponential backoff, capped at `maxDelayMs`; a requested wait beyond the cap fails fast rather than sleeping. On exhaustion, enriches the final error with attempt count in message and `data.retryAttempts`. **Place the retry boundary around the full pipeline** (fetch + parse), not just the network call. `RetryOptions`: `maxRetries` (default `3`), `baseDelayMs` (default `1000`), `maxDelayMs` (default `30000`), `jitter` (default `0.25`), `operation` (log label), `context` (RequestContext), `signal` (AbortSignal), `isTransient` (custom predicate), `deadlineMs` (total wall-clock budget — see below). |
36
36
  | `RetryAttempt` | `{ readonly signal: AbortSignal; readonly remainingMs: number }` | What `fn` receives each attempt. `signal` is `AbortSignal.any` over the `deadlineMs` clock and `options.signal`; `remainingMs` is what is left of the total budget as the attempt starts, never negative and `Number.POSITIVE_INFINITY` when no deadline is set — so `Math.min(perAttemptMs, remainingMs)` is correct either way. A zero-argument `fn` stays assignable, so existing callers compile unchanged. |
37
- | `deadlineMs` | `RetryOptions` field | One wall-clock budget across every attempt, backoff, and honored `Retry-After` — the bound `maxRetries` plus a per-attempt timeout cannot express. Four 30s attempts outlast a client's 60s request timeout, so the caller gets a transport timeout instead of the server's classified error. **Thread `attempt.signal` into the attempt's I/O** (`fetchWithTimeout(url, Math.min(30_000, remainingMs), ctx, { signal })`) or the deadline overshoots by one in-flight request. Clock is `AbortController` + `setTimeout` (never `AbortSignal.timeout()`, per the Bun realm mismatch), cleared on return — no timer outlives the call. Expiry rejects with `Timeout` (-32004) carrying `data: { reason: 'retry_deadline_exceeded', deadlineMs, elapsedMs, retryAttempts }` and the last attempt's error as `cause`; **one shape for every expiry**, including the `RequestCancelled` that an external-signal abort raises inside `fetchWithTimeout` and the raw abort reason a mid-backoff expiry would otherwise surface. No `retryable` flag (a narrower call can still succeed) and no `attempt` index (`retryAttempts` carries it). A backoff that would outlast the remaining budget fails fast with the expiry instead of sleeping into a certain timeout; an honored `Retry-After` that would outlast it takes the `maxDelayMs` exit instead — the attempt's error unchanged, `data.retryAfter` intact, since "wait the window the upstream named" is still the caller's action. **Three clocks stay distinct:** a caller abort on `options.signal` keeps precedence and rethrows unchanged (stamped `RequestCancelled` by the handler factory), a single attempt's timeout is `Timeout` with `errorSource: 'FetchTimeout'` and no `reason`, and the expiry is `Timeout` with the `reason`. Unset, behavior is identical to before — attempt counts, delays, log lines, and the exhausted-error shape untouched. Bounds **one** ladder: a tool making three upstream calls threads its own remaining budget into each. |
38
- | `defaultIsTransient` | `(error: unknown) -> boolean` | The predicate `withRetry` uses when `isTransient` is omitted: an `McpError` with a transient code (`ServiceUnavailable`, `Timeout`, `RateLimited`) unless it carries `data.retryable === false` or `data.reason === 'pacer_shed'`; any non-`McpError` throw is assumed transient. Exported so `isTransient` — which **replaces** the default outright — can compose instead of mirroring the transient set, which drifts silently when the framework's classification changes: `isTransient: (error) => !isMyBudgetRefusal(error) && defaultIsTransient(error)`, or the inverse `defaultIsTransient(error) \|\| isMyRetryableShape(error)`. The transient code set itself stays private (a module-level `Set` an exported binding could be mutated into framework-wide retry behavior). |
37
+ | `deadlineMs` | `RetryOptions` field | One wall-clock budget across every attempt, backoff, and honored `Retry-After` — the bound `maxRetries` plus a per-attempt timeout cannot express. Four 30s attempts outlast a client's 60s request timeout, so the caller gets a transport timeout instead of the server's classified error. **Thread `attempt.signal` into the attempt's I/O** (`fetchWithTimeout(url, Math.min(30_000, remainingMs), ctx, { signal })`) or the deadline overshoots by one in-flight request. Clock is `AbortController` + `setTimeout` (never `AbortSignal.timeout()`, per the Bun realm mismatch), cleared on return — no timer outlives the call. Expiry rejects with `Timeout` (-32004) carrying `data: { reason: 'retry_deadline_exceeded', deadlineMs, elapsedMs, retryAttempts }` and the last attempt's error as `cause`; **one shape for every expiry**, including the per-attempt `Timeout` (`errorSource: 'FetchSignalTimeout'`) the clock's abort raises inside `fetchWithTimeout` and the raw abort reason a mid-backoff expiry would otherwise surface. No `retryable` flag (a narrower call can still succeed) and no `attempt` index (`retryAttempts` carries it). A backoff that would outlast the remaining budget fails fast with the expiry instead of sleeping into a certain timeout; an honored `Retry-After` that would outlast it takes the `maxDelayMs` exit instead — the attempt's error unchanged, `data.retryAfter` intact, since "wait the window the upstream named" is still the caller's action. **Three clocks stay distinct:** a caller abort on `options.signal` keeps precedence — mid-attempt it rethrows the attempt's error unchanged, mid-backoff it rejects with `signal.reason` itself (an `AbortError` `DOMException` for a reason-less `abort()`), and the handler factory reports either as `RequestCancelled` when the request signal is the one that fired — a single attempt's timeout is `Timeout` with `errorSource: 'FetchTimeout'` and no `reason`, and the expiry is `Timeout` with the `reason`. Unset, behavior is identical to before — attempt counts, delays, log lines, and the exhausted-error shape untouched. Bounds **one** ladder: a tool making three upstream calls threads its own remaining budget into each. |
38
+ | `defaultIsTransient` | `(error: unknown) -> boolean` | The predicate `withRetry` uses when `isTransient` is omitted: an `McpError` with a transient code (`ServiceUnavailable`, `Timeout`, `RateLimited`) unless it carries `data.retryable === false`, `data.reason === 'pacer_shed'`, or `data.errorSource === 'FetchSignalTimeout'` (a caller-side deadline that already fired); any non-`McpError` throw is assumed transient. Exported so `isTransient` — which **replaces** the default outright — can compose instead of mirroring the transient set, which drifts silently when the framework's classification changes: `isTransient: (error) => !isMyBudgetRefusal(error) && defaultIsTransient(error)`, or the inverse `defaultIsTransient(error) \|\| isMyRetryableShape(error)`. The transient code set itself stays private (a module-level `Set` an exported binding could be mutated into framework-wide retry behavior). |
39
39
  | `httpErrorFromResponse` | `(response: Response, options?: HttpErrorFromResponseOptions) -> Promise<McpError>` | Maps an HTTP `Response` to a properly classified `McpError` — full status table including 401/403/408/422/429/5xx, body capture (truncated), `retry-after` header, optional `cause`. `error.data` carries `status`/`body` plus the legacy `statusCode`/`responseBody` aliases (identical values), so a consumer can classify either helper's error without knowing which raised it. Use this instead of hand-rolling `if (status === 429) ...` ladders. Reads the response body — `clone()` first if you need it elsewhere. **`error.data` is client-facing** — the framework forwards it verbatim as `structuredContent.error.data` — so the full upstream URL is **omitted by default**: a request URL routinely carries user input, internal identifiers, or an API key in its query string. `includeUrl: true` opts into `data.url` carrying the full `response.url`; with an empty `response.url` no key is added either way, and the message still names the host. Response headers are opt-in on the same footing: `errorHeaders: ['x-ratelimit-remaining-usd', 'x-request-id']` copies the named headers onto `data.headers` under **lowercase** keys — selection is case-insensitive and entries differing only in case collapse to one key, presence follows `Headers.has()` (an empty value is captured as `''`, an absent header adds no key), and a multi-valued field is captured comma-joined as `Headers.get()` returns it. Omitted, empty, or matching nothing, no `headers` key is emitted. `set-cookie` is **never** captured whatever the selector says: it is credential-bearing and `Headers.get()` joins its values into a string that is not a valid reconstruction. Every selected value reaches the client, so never name a header that carries a credential — and a selected `Location` can itself carry a sensitive path, query, or token. `HttpErrorFromResponseOptions`: `service?` (logical name in message, e.g. `'NCBI'`), `captureBody?` (default `true`), `bodyLimit?` (default `500`), `includeUrl?` (default `false`), `errorHeaders?` (default none), `data?` (extra fields merged into `error.data`, overriding defaults on key collision — a caller's own `url` or `headers` still reaches the wire), `cause?`, `codeOverride?` (per-status mapping override). Pairs naturally with `withRetry` — both classify codes the same way. A 501 also carries `data.retryable: false`, so retry fails it fast instead of re-asking for a method the upstream does not implement. |
40
- | `createPacer` | `(options: PacerOptions) -> Pacer` | FIFO queue in front of one rate-limited upstream — the outbound counterpart to `RateLimiter` (`utils/security`), which is inbound, per-caller, and reject-only, so it cannot queue work against an upstream budget. `pacer.run(task, { signal?, maxWaitMs? })` holds `task` until every `limits` window, `minStartGapMs`, `maxConcurrent`, and the cooldown gate allow it, then calls it with the caller's signal. `PacerOptions`: `name` (author-set telemetry label), `limits` (`{ requests, perMs }[]` — each a sliding window over recorded **start** times, so a slow response never widens the rate the upstream sees; all must allow a start), `minStartGapMs` (**not** expressible through `limits`: `{ requests: 10, perMs: 1000 }` permits ten starts in the same millisecond), `maxConcurrent`, `maxQueueDepth` (absolute backpressure for callers passing no `maxWaitMs`; rejects without arming a timer), `cooldown` (`{ baseMs, maxMs }`). **Shed:** `maxWaitMs` bounds queue time only, never the task. The projected wait is exact over the windows and the gap but a lower bound once `maxConcurrent` binds (a slot frees on an unknowable completion), so enqueue rejects only when that lower bound already exceeds `maxWaitMs` — no false sheds — and a still-queued entry rejects when `maxWaitMs` elapses. The shed error is `rateLimited` (-32003) with `data: { reason: 'pacer_shed', retryAfter, queueDepth }` and **no `retryable: false`** — to the calling agent a shed is an ordinary rate limit (wait `retryAfter`, call again) and that flag would say the opposite; `defaultIsTransient` reads the `reason` instead, so an enclosing `withRetry` fails fast rather than sleeping past the deadline the shed enforces. **Cooldown gate:** a `RateLimited` thrown by the task closes the gate for every queued caller until an absolute instant, `min(max(baseMs · 2^(consecutive−1), retryAfter), maxMs)` — `maxMs` caps both the doubling and an honored `Retry-After`, so a pathological upstream value cannot park the queue. Absent or unparseable `retryAfter` leaves the doubling; any other error leaves the gate open; the first success resets the count. **Composition:** `withRetry(({ signal }) => pacer.run(fn, { signal }), { signal, deadlineMs })` — retry outside, pacer inside, so each attempt re-queues and is re-paced. Because the gate is an absolute instant rather than a duration counted from dequeue, retry's `Retry-After` sleep and the gate overlap in wall-clock instead of summing: the window is waited once, not twice. **Lifecycle:** timers and `AbortSignal` only, process-local; the dispatch timer is `unref()`'d where supported; `dispose()` / `[Symbol.dispose]()` clears it and rejects queued waiters with `RequestCancelled` (in-flight tasks are left to finish) — wire it through `createApp({ teardown })`. On Workers state is per-isolate so the limits bind per isolate, OTel is off so the metrics are inert, and `createWorkerHandler` accepts no `teardown`. Metrics: `mcp.pacer.queue_depth`, `mcp.pacer.wait`, `mcp.pacer.sheds`, `mcp.pacer.cooldowns`, attributed by `mcp.pacer.name` only — see `api-telemetry`. |
40
+ | `createPacer` | `(options: PacerOptions) -> Pacer` | FIFO queue in front of one rate-limited upstream — the outbound counterpart to `RateLimiter` (`utils/security`), which is inbound, per-caller, and reject-only, so it cannot queue work against an upstream budget. `pacer.run(task, { signal?, maxWaitMs? })` holds `task` until every `limits` window, `minStartGapMs`, `maxConcurrent`, and the cooldown gate allow it, then calls it with the caller's signal. `PacerOptions`: `name` (author-set telemetry label), `limits` (`{ requests, perMs }[]` — each a sliding window over recorded **start** times, so a slow response never widens the rate the upstream sees; all must allow a start), `minStartGapMs` (**not** expressible through `limits`: `{ requests: 10, perMs: 1000 }` permits ten starts in the same millisecond), `maxConcurrent`, `maxQueueDepth` (absolute backpressure for callers passing no `maxWaitMs`; rejects without arming a timer; bounds **waiters only** — an arrival whose slot is open that instant starts without queueing, so `0` means "run when a slot is free, never wait"), `cooldown` (`{ baseMs, maxMs }`). **Shed:** `maxWaitMs` bounds queue time only, never the task. The projected wait is exact over the windows and the gap but a lower bound once `maxConcurrent` binds (a slot frees on an unknowable completion), so enqueue rejects only when that lower bound already exceeds `maxWaitMs` — no false sheds — and a still-queued entry rejects when `maxWaitMs` elapses, unless its slot opens that same instant. The shed error is `rateLimited` (-32003) with `data: { reason: 'pacer_shed', shedKind, retryAfter, queueDepth }`. `shedKind` (`PacerShedKind`) is `queue_full` (the call would wait behind `maxQueueDepth` waiters), `wait_projected` (the enqueue projection exceeds `maxWaitMs`), or `wait_elapsed` (`maxWaitMs` ran out while queued), and the message follows the kind — a `queue_full` shed names the full queue, not a wait budget. `retryAfter` is seconds until a caller joining behind every remaining waiter could start; while `maxConcurrent` is saturated — a release the projection cannot see — it is floored at the longest wait of any queued caller, the shed one included, minimum 1. `queueDepth` is the waiters still queued. **No `retryable: false`** — to the calling agent a shed is an ordinary rate limit (wait `retryAfter`, call again) and that flag would say the opposite; `defaultIsTransient` reads the `reason` instead, so an enclosing `withRetry` fails fast rather than sleeping past the deadline the shed enforces. **Cooldown gate:** a `RateLimited` thrown by the task closes the gate for every queued caller until an absolute instant, `min(max(baseMs · 2^(consecutive−1), retryAfter), maxMs)` — `maxMs` caps both the doubling and an honored `Retry-After`, so a pathological upstream value cannot park the queue. Absent or unparseable `retryAfter` leaves the doubling; any other error leaves the gate open and the count untouched, and a shed (`reason: 'pacer_shed'`) from a pacer nested inside the task is local backpressure, never a gate closure. The first success resets the count, and so does a gate that has stood open for `maxMs`: the next rate limit starts over at `baseMs`, while one arriving sooner — the gate still closed included — keeps doubling, so continuous demand under a sustained limit keeps its capped backoff. **`pacer.cooldown`** samples the gate as `PacerCooldownState` `{ remainingMs, consecutive }`: `remainingMs` is the shared gate, not one rate limit's own computation (rate limits landing together close one gate at the later instant), so a task's rejection handler can report it on the server's own error — the pacer never writes to the task's error. Both stay 0 without `cooldown`. **Composition:** `withRetry(({ signal }) => pacer.run(fn, { signal }), { signal, deadlineMs })` — retry outside, pacer inside, so each attempt re-queues and is re-paced. Because the gate is an absolute instant rather than a duration counted from dequeue, retry's `Retry-After` sleep and the gate overlap in wall-clock instead of summing: the window is waited once, not twice. **Lifecycle:** timers and `AbortSignal` only, process-local; the dispatch timer is `unref()`'d where supported; `dispose()` / `[Symbol.dispose]()` clears it and rejects queued waiters with `RequestCancelled` (in-flight tasks are left to finish) — wire it through `createApp({ teardown })`. On Workers state is per-isolate so the limits bind per isolate, OTel is off so the metrics are inert, and `createWorkerHandler` accepts no `teardown`. Metrics: `mcp.pacer.queue_depth`, `mcp.pacer.wait`, `mcp.pacer.sheds`, `mcp.pacer.cooldowns`, attributed by `mcp.pacer.name` only — see `api-telemetry`. |
41
41
  | `httpStatusToErrorCode` | `(status: number) -> JsonRpcErrorCode \| undefined` | Sync status → code lookup. Returns `undefined` for 1xx/2xx. A 3xx maps to `InvalidRequest` — it reaches error mapping under `redirect: 'manual'`, where the request as sent cannot be served at this URL, and that code is outside `withRetry`'s transient set since re-issuing returns the same redirect. Use when you need just the code without a `Response` object handy. No status maps to `InternalError` — that code means *this* server failed, which a remote status cannot establish; every 5xx is `ServiceUnavailable` (or `Timeout` for 504) and so picks up `withRetry`'s default transient policy. |
42
42
 
43
43
  ---
@@ -47,9 +47,9 @@ Utility exports from `@cyanheads/mcp-ts-core/utils`. Utilities with complex APIs
47
47
  | Export | API | Notes |
48
48
  |:-------|:----|:------|
49
49
  | `extractCursor` | `(params?) -> string \| undefined` | Extracts opaque cursor string from MCP request params. Checks `params.cursor` then `params._meta.cursor`. Returns `undefined` when no cursor is present. Does not decode. |
50
- | `paginateArray` | `<T>(items, cursorStr, defaultPageSize, maxPageSize, context: RequestContext) -> PaginatedResult<T>` | Decodes cursor, slices array, returns `{ items, nextCursor?, totalCount }`. `nextCursor` omitted on last page. Throws `McpError(InvalidParams)` on invalid cursor. On a continued call the page size comes from the cursor, not `defaultPageSize` — a tool with a caller-facing `limit` input must slice on `decodeCursor(...).offset` itself to honor `limit` past page 1. |
50
+ | `paginateArray` | `<T>(items, cursorStr, defaultPageSize, maxPageSize, context: RequestContext) -> PaginatedResult<T>` | Decodes cursor, slices array, returns `{ items, nextCursor?, totalCount }`. `nextCursor` omitted on last page. Throws `McpError(InvalidParams)` on invalid cursor, inherited from `decodeCursor` (`data.reason: 'invalid_cursor'`). On a continued call the page size comes from the cursor, not `defaultPageSize` — a tool with a caller-facing `limit` input must slice on `decodeCursor(...).offset` itself to honor `limit` past page 1. |
51
51
  | `encodeCursor` | `(state: PaginationState) -> string` | Encodes `{ offset, limit, ...extra }` to opaque base64url string. |
52
- | `decodeCursor` | `(cursor, context: RequestContext) -> PaginationState` | Decodes opaque base64url cursor. Throws `McpError(InvalidParams)` if malformed. |
52
+ | `decodeCursor` | `(cursor, context: RequestContext) -> PaginationState` | Decodes opaque base64url cursor. Throws `McpError(InvalidParams)` if malformed, with `data: { cursor, reason: 'invalid_cursor', recovery: { hint } }` — the hint tells the caller to omit `cursor` or pass the previous `nextCursor` unchanged, so the rejection renders the `Recovery:` line and `(reason invalid_cursor)` trailer like any other classified failure. |
53
53
 
54
54
  ---
55
55
 
@@ -85,7 +85,7 @@ The `utils` export includes two type guards. The full set of guards lives in the
85
85
  | Export | API | Notes |
86
86
  |:-------|:----|:------|
87
87
  | `Logger` | Class | The `Logger` class itself. Use `Logger.getInstance()` if needed; most consumers use the `logger` singleton. |
88
- | `logger` | `Logger` instance (wraps Pino). `.debug(msg, ctx?)` `.info(msg, ctx?)` `.notice(msg, ctx?)` `.warning(msg, ctx?)` `.error(msg, errorOrCtx, ctx?)` `.crit(msg, errorOrCtx, ctx?)` `.alert(msg, errorOrCtx, ctx?)` `.emerg(msg, errorOrCtx, ctx?)` `.fatal(msg, errorOrCtx, ctx?)` | Global structured logger. Use `ctx.log` in handlers instead. `logger` is for lifecycle/background contexts (startup, shutdown, `setup()`). Auto-redacts sensitive fields. Records logged before the framework initializes the logger — anything in `setup()` — are held in a 250-record buffer and replayed once the sinks exist, filtered against the level the logger starts with. **Note:** `.error()` and higher accept `(msg, Error, ctx?)` or `(msg, ctx?)` — the second arg is overloaded. `.fatal()` is an alias for `.emerg()`. Full RFC 5424 severity set. |
88
+ | `logger` | `Logger` instance (wraps Pino). `.debug(msg, ctx?)` `.info(msg, ctx?)` `.notice(msg, ctx?)` `.warning(msg, ctx?)` `.error(msg, errorOrCtx, ctx?)` `.crit(msg, errorOrCtx, ctx?)` `.alert(msg, errorOrCtx, ctx?)` `.emerg(msg, errorOrCtx, ctx?)` `.fatal(msg, errorOrCtx, ctx?)` | Global structured logger. Use `ctx.log` in handlers instead. `logger` is for lifecycle/background contexts (startup, shutdown, `setup()`). Auto-redacts sensitive fields. The context's `extra` bag is flattened into the record, but a canonical field the context carries (`requestId`, `timestamp`, `traceId`, `spanId`, `sessionId`, `tenantId`, `operation`) always wins over an `extra` key of the same name. Records logged before the framework initializes the logger — anything in `setup()` — are held in a 250-record buffer and replayed once the sinks exist, filtered against the level the logger starts with. **Note:** `.error()` and higher accept `(msg, Error, ctx?)` or `(msg, ctx?)` — the second arg is overloaded. `.fatal()` is an alias for `.emerg()`. Full RFC 5424 severity set. |
89
89
  | `McpLogLevel` | Type | Log level union type for typing level variables. |
90
90
 
91
91
  ---
@@ -109,7 +109,7 @@ The `utils` export includes two type guards. The full set of guards lives in the
109
109
 
110
110
  | Export | API | Notes |
111
111
  |:-------|:----|:------|
112
- | `ErrorHandler` | `.tryCatch<T>(fn, opts) -> Promise<T>` `.handleError(error, opts) -> Error` `.classifyOnly(error) -> { code, message, data? }` `.determineErrorCode(error) -> JsonRpcErrorCode` `.mapError(error, mappings, defaultFactory?) -> T \| Error` `.formatError(error) -> Record<string, unknown>` | Service-level error handling. `tryCatch` wraps async or sync `fn`, logs via `handleError`, and always rethrows. No `.tryCatchSync()`. Use in services, NOT in tool handlers (those throw raw `McpError`). `tryCatch` accepts `Omit<ErrorHandlerOptions, 'rethrow'>` — required: `operation`. Optional: `context`, `errorCode`, `input`, `includeStack`, `critical`, `errorMapper`. `handleError` accepts the full `ErrorHandlerOptions` including `rethrow`. |
112
+ | `ErrorHandler` | `.tryCatch<T>(fn, opts) -> Promise<T>` `.handleError(error, opts) -> Error` `.classifyOnly(error) -> { code, message, data? }` `.determineErrorCode(error) -> JsonRpcErrorCode` `.mapError(error, mappings, defaultFactory?) -> T \| Error` `.formatError(error) -> Record<string, unknown>` | Service-level error handling. `tryCatch` wraps async or sync `fn`, logs via `handleError`, and always rethrows. No `.tryCatchSync()`. Use in services, NOT in tool handlers (those throw raw `McpError`). `tryCatch` accepts `Omit<ErrorHandlerOptions, 'rethrow'>` — required: `operation`. Optional: `context`, `errorCode`, `input`, `includeStack`, `critical`, `errorMapper`. `handleError` accepts the full `ErrorHandlerOptions` including `rethrow`. The returned error's `data` (client-visible once thrown toward a handler) keeps the caught `McpError`'s own `data` plus `originalErrorName`/`originalMessage`/`rootCause`; `context` goes to the log record only. |
113
113
 
114
114
  ---
115
115
 
@@ -148,7 +148,7 @@ Helper API only. For the catalog of what the framework auto-emits (span names, m
148
148
 
149
149
  | Export | Signature | Notes |
150
150
  |:-------|:----------|:------|
151
- | `initializeOpenTelemetry` | `() -> Promise<void>` | Idempotent. Initializes `NodeSDK` with OTLP trace + metrics exporters, `TraceIdRatioBasedSampler`, HTTP instrumentation, and Pino log injection. No-ops when `OTEL_ENABLED=false` or in Worker/Edge runtimes where `NodeSDK` is unavailable. Safe to call multiple times. |
151
+ | `initializeOpenTelemetry` | `() -> Promise<void>` | Idempotent. Initializes `NodeSDK` with OTLP trace + metrics exporters, `TraceIdRatioBasedSampler`, and HTTP instrumentation, and attaches the framework logger's OTLP log sink when `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` is set. Framework log records carry `traceId`/`spanId` from the request context; `PinoInstrumentation` is registered but patches only a `pino` loaded after the SDK starts, never the framework logger's. No-ops when `OTEL_ENABLED=false` or in Worker/Edge runtimes where `NodeSDK` is unavailable. Safe to call multiple times. |
152
152
  | `shutdownOpenTelemetry` | `(timeoutMs?: number) -> Promise<void>` | Gracefully flushes and shuts down the SDK. `timeoutMs` defaults to `5000`. Resets internal state so the next `initializeOpenTelemetry()` call can reinitialize. No-op when SDK was never started. |
153
153
  | `sdk` | `NodeSDK \| null` | The live SDK instance, or `null` when telemetry is disabled, in a Worker runtime, or after shutdown. |
154
154
 
@@ -22,7 +22,7 @@ import { markdown, MarkdownBuilder, diffFormatter, tableFormatter, treeFormatter
22
22
  | `keyValuePlain` | `(key, value) -> this` | `key: value` (no bold) |
23
23
  | `list` | `(items, ordered?) -> this` | `ordered` defaults to `false`; empty arrays silently ignored |
24
24
  | `codeBlock` | `(content, language?) -> this` | Fenced block; `language` defaults to `''`. The fence outgrows the longest backtick run in `content`, so a payload the tool did not author (upstream text, a file excerpt) cannot break out of the block; content is emitted byte-for-byte |
25
- | `inlineCode` | `(code) -> this` | Backtick-wrapped; no trailing newline |
25
+ | `inlineCode` | `(code) -> this` | Code span; no trailing newline. The backtick delimiter outgrows the longest backtick run in `code`, space-padded when a backtick touches either end or the value both begins and ends with a space, so a value the tool did not author reads back byte-identical as one span and cannot break out into live markdown. A value with no backtick that does not both begin and end with a space renders as `` `code` `` |
26
26
  | `paragraph` | `(text) -> this` | Text + `\n\n` |
27
27
  | `blockquote` | `(text) -> this` | Each line prefixed with `>` + space |
28
28
  | `hr` | `() -> this` | `---` |
@@ -12,7 +12,7 @@ All parsers are **Tier 3** — lazy-load their peer dependency on first call. Al
12
12
  - `<think>...</think>` blocks at the start of input are automatically stripped and logged at `debug` level (except `dateParser` and `pdfParser`)
13
13
  - Every `context?` parameter is optional (synthetic context created if omitted) and accepts the handler `Context` as well as a `RequestContext` bag
14
14
  - Input budgets are opt-in: a parser is unbounded unless the caller passes `maxBytes`, which then rejects an over-budget input with `ValidationError` (`reason: 'parser_input_too_large'`). `DEFAULT_TEXT_PARSER_MAX_BYTES` (1 MiB) and `DEFAULT_BINARY_PARSER_MAX_BYTES` (25 MiB) are exported as starting points, not applied defaults
15
- - Errors throw `McpError` — never return error values. The message is `<summary>: <library message>`, so it carries the underlying parser's diagnostic; `data` carries only `{ reason }`, and the input sample and stack stay on `cause`
15
+ - Errors throw `McpError` — never return error values. The message is `<summary>: <library message>`, so it carries the underlying parser's diagnostic; `data` carries only `{ reason }` (`csvParser` adds Papa's `errors` list and a content sample), and the input sample and stack stay on `cause`. Input that is empty after `<think>` stripping and trimming rejects with `ValidationError` (`reason: 'parser_input_empty'`). The `context` you pass is for log correlation only — it never becomes error `data`, which reaches the client
16
16
 
17
17
  ---
18
18
 
@@ -56,7 +56,7 @@ const data = await xmlParser.parse<FeedResponse>(xmlString);
56
56
  |:-------|:----------|
57
57
  | `parse` | `<T = unknown>(csvString, options?, context?) -> Promise<Papa.ParseResult<T>>` |
58
58
 
59
- `options` is `Papa.ParseConfig` forwarded verbatim — key options: `header`, `delimiter`, `dynamicTyping`. Returns `{ data: T[], errors: ParseError[], meta: ParseMeta }`. Throws `ValidationError` if `result.errors` is non-empty.
59
+ `options` is `Papa.ParseConfig` forwarded verbatim — key options: `header`, `delimiter`, `dynamicTyping`. Returns `{ data: T[], errors: ParseError[], meta: ParseMeta }`. Throws `ValidationError` if `result.errors` is non-empty, with `data: { reason: 'csv_parse_failed', errors, originalContentSample }`.
60
60
 
61
61
  ```ts
62
62
  const result = await csvParser.parse<Row>(csvString, { header: true, dynamicTyping: true });
@@ -21,7 +21,7 @@ Pre-constructed singleton of `Sanitization`. Tier 3 peer: `sanitize-html` (HTML
21
21
  | `sanitizePath` | **no** | Node.js only | `(input, options?) -> SanitizedPathInfo` |
22
22
  | `sanitizeJson` | **no** | none | `<T>(input, maxSize?) -> T` |
23
23
  | `sanitizeForLogging` | **no** | none | `(input) -> unknown` |
24
- | `redactSensitiveFields` | **no** | none | `(data, fields?, ctx?) -> unknown` |
24
+ | `serializeForLogging` | **no** | none | `(value, maxBytes) -> { text: string; truncated: boolean }` |
25
25
  | `getSensitivePinoFields` | **no** | none | `() -> string[]` |
26
26
 
27
27
  ### Option types
@@ -65,6 +65,8 @@ interface SanitizedPathInfo {
65
65
  - `sanitizeJson`: `maxSize` is bytes (UTF-8); uses `Buffer.byteLength` / `TextEncoder` / `string.length` fallback chain
66
66
  - `sanitizeNumber`: `NaN`/`Infinity` always rejected; out-of-range values silently clamped with debug log
67
67
  - `sanitizeForLogging`: deep clones via `structuredClone`; returns `'[Log Sanitization Failed]'` on clone error
68
+ - **Rejection reasons.** Every `ValidationError` carries `data.reason`, and a `data.recovery.hint` wherever the caller can change the input: `invalid_url` (`sanitizeUrl`; the hint names the allowed schemes), `invalid_path` / `path_traversal` / `absolute_path_disallowed` (`sanitizePath`), `invalid_json` / `json_too_large` (`sanitizeJson`; the latter names the byte cap), `invalid_number` (`sanitizeNumber`), and `unsupported_sanitize_context` (`sanitizeString`'s `'javascript'` context, which has no hint — it is a server-code choice)
69
+ - `serializeForLogging`: `sanitizeForLogging`, then `JSON.stringify`, then a cut to at most `maxBytes` UTF-8 bytes on a character boundary — redaction first, so a cut never keeps part of a secret. A truncated `text` is a prefix of the whole serialization and no longer valid JSON; `truncated` says so. Returns a string so a deep payload survives the logger's four-level field depth. A value `JSON.stringify` rejects (a `bigint`) yields `'[Log Serialization Failed]'`. Backs the failed-call payload record (`LOG_TOOL_FAILURE_PAYLOADS`)
68
70
 
69
71
  ### Sensitive fields
70
72
 
@@ -191,10 +193,10 @@ interface IdGenerationOptions {
191
193
  | Method | Signature | Notes |
192
194
  |:-------|:----------|:------|
193
195
  | `generate` | `(prefix?, options?) -> string` | `PREFIX_XXXXXX` or just `XXXXXX` if no prefix |
194
- | `generateForEntity` | `(entityType, options?) -> string` | Uses registered prefix; throws `McpError(ValidationError)` if type unknown |
195
- | `generateRandomString` | `(length?, charset?) -> string` | Raw random string; defaults: length 6, charset `A-Z0-9` |
196
+ | `generateForEntity` | `(entityType, options?) -> string` | Uses registered prefix; throws `McpError(ValidationError)` with `data.reason: 'unknown_entity_type'` if type unknown |
197
+ | `generateRandomString` | `(length?, charset?) -> string` | Raw random string; defaults: length 6, charset `A-Z0-9`. A charset outside 1–256 characters throws `ValidationError` with `data.reason: 'invalid_charset'` |
196
198
  | `isValid` | `(id, entityType, options?) -> boolean` | Regex-validates format against prefix + separator + charset{length} |
197
- | `getEntityType` | `(id, separator?) -> string` | Resolves entity type from prefix; throws `McpError(ValidationError)` if unknown |
199
+ | `getEntityType` | `(id, separator?) -> string` | Resolves entity type from prefix; throws `McpError(ValidationError)` — `data.reason: 'invalid_id_format'` when the ID has no `PREFIX<sep>` part, `'unknown_entity_type'` when the prefix is unregistered, each with a `recovery.hint` (the latter lists the registered prefixes) |
198
200
  | `normalize` | `(id, separator?) -> string` | Canonical prefix casing + uppercase random part |
199
201
  | `stripPrefix` | `(id, separator?) -> string` | Returns random part; returns original if separator not found |
200
202
  | `setEntityPrefixes` | `(config) -> void` | Replaces all prefixes and rebuilds reverse lookup |
@@ -4,7 +4,7 @@ description: >
4
4
  Design the tool surface, resources, and service layer for a new MCP server. Use when starting a new server, planning a major feature expansion, or when the user describes a domain/API they want to expose via MCP. Produces a design doc at docs/design.md that drives implementation.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.29"
7
+ version: "2.30"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -332,7 +332,7 @@ nctIds: z.union([z.string(), z.array(z.string()).max(5)])
332
332
  | Delimiter-joined list where an array is accepted | `"US,JP,KR"` → `["US","JP","KR"]` | Split on the documented separator |
333
333
  | Spelled-out vs. abbreviated name | `"Houston, Texas"` → `"Houston, TX"` | Normalize against the bundled name table |
334
334
 
335
- These are **value**-level, and the mappings are domain knowledge — settle them per input in the design doc's param table. Argument **key** names are not: the framework drops client-added root keys and rewrites declared and case-style key aliases before the schema sees the arguments, and repairs a JSON-stringified array against the tool's own schema after a failed parse. Don't re-implement any of that per server — see `add-tool` § *Three things the framework fixes before the schema sees the arguments*.
335
+ These are **value**-level, and the mappings are domain knowledge — settle them per input in the design doc's param table. Argument **key** names are not: the framework rewrites declared and case-style key aliases and drops client-added root keys before the schema sees the arguments, and repairs a JSON-stringified array or object, or an integer sent for a string, against the tool's own schema after a failed parse — so an ID field stays `z.string()`, never a `string | number` union. Don't re-implement any of that per server — see `add-tool` § *Three things the framework fixes before the schema sees the arguments*.
336
336
 
337
337
  This resolves one submitted value to one canonical value, and does not loosen the strict token match in [MCP-side list filtering](#mcp-side-list-filtering), which scores a query against many candidate names.
338
338
 
@@ -4,7 +4,7 @@ description: >
4
4
  Exercise tools, resources, and prompts against a live HTTP server via MCP JSON-RPC over curl. Starts the server, surfaces the catalog, runs real and adversarial inputs, measures every call (bytes, token estimate, wall-clock) and weighs the catalog, and produces a tight report with concrete findings and numbered follow-up options. Use after adding or modifying definitions, or when the user asks to test, try out, or verify their MCP surface.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.16"
7
+ version: "2.17"
8
8
  audience: external
9
9
  type: debug
10
10
  ---
@@ -19,7 +19,7 @@ Unit tests (`add-test` skill) verify handler logic with mocked context. Field te
19
19
 
20
20
  This skill drives an HTTP server because curl + JSON-RPC is the most reliable harness for shell-based agents. The same handlers run on both transports — only the framing differs — so HTTP exercises the full functional surface. Both HTTP session modes are covered: a durable `Mcp-Session-Id` session, and the sessionless initialization a `MCP_SESSION_MODE=stateless` server performs.
21
21
 
22
- **Stdio coverage is a boot check only — run this before Step 1.** Run `bun run rebuild && bun run start:stdio < /dev/null`, and confirm the startup logs look clean (banner, expected tool/resource counts, no errors/warnings, no missing-config gripes). Redirecting stdin is what ends the run: the server treats EOF as a shutdown signal, boots fully, then exits on its own, so the log also shows the graceful-shutdown path. Do not background it and reach for `pkill` — a pattern like `pkill -f dist/index.js` matches every other stdio MCP server on the machine, including the ones the calling agent's own session is connected to. Pino logs go to stderr in stdio mode (stdout is reserved for JSON-RPC), so they print straight to the terminal when you run interactively. No need to call tools over stdio — the HTTP pass already covered handler behavior.
22
+ **Stdio coverage is a boot check only — run this before Step 1.** Run `bun run rebuild && bun run start:stdio < /dev/null`, and confirm the startup logs look clean: the `Core services constructed — N tool(s) …` record lists every registered tool, resource, and prompt in its `tools` / `resources` / `prompts` fields — the message text shows only counts — and a definition missing from them was never passed to `createApp()`. No errors/warnings, no missing-config gripes. The emoji startup banner prints only to a terminal, so its absence from an agent's shell is not a finding. Redirecting stdin is what ends the run: the server treats EOF as a shutdown signal, boots fully, then exits on its own, so the log also shows the graceful-shutdown path. Do not background it and reach for `pkill` — a pattern like `pkill -f dist/index.js` matches every other stdio MCP server on the machine, including the ones the calling agent's own session is connected to. Pino logs go to stderr in stdio mode (stdout is reserved for JSON-RPC), so they print straight to the terminal when you run interactively. No need to call tools over stdio — the HTTP pass already covered handler behavior.
23
23
 
24
24
  ---
25
25
 
@@ -402,7 +402,7 @@ Treat any hit as a `ux` finding in the report. The authoring rule lives under *T
402
402
  |:------------------------------------------------|:-------------|
403
403
  | `include` / `fields` / `expand` / `view` / `projection` parameter | Field selection: non-default value renders requested fields |
404
404
  | Array return with `query` / `filter` inputs | Empty result: does response explain *why* (echo criteria, suggest broadening)? |
405
- | Identifier, code, or enum-ish input (an ID format, a classification code, a unit, a place name, a list the docs say may be comma-joined) | Value-variant tolerance: re-send the happy-path call with each obvious variant of that value — lowercase, the bare leaf of a hierarchical code, a common domain alias, a delimiter-joined list where an array is accepted, the spelled-out form of an abbreviated name. Pass is either outcome: the call succeeds, or it fails with an error naming the expected shape. A miss or a bare validation failure on a variant that maps one-to-one onto a valid value is a `ux` finding. Probe **values** — variants of the argument *key* name, and a JSON-stringified array as a value, are handled by the framework, not the server. |
405
+ | Identifier, code, or enum-ish input (an ID format, a classification code, a unit, a place name, a list the docs say may be comma-joined) | Value-variant tolerance: re-send the happy-path call with each obvious variant of that value — lowercase, the bare leaf of a hierarchical code, a common domain alias, a delimiter-joined list where an array is accepted, the spelled-out form of an abbreviated name. Pass is either outcome: the call succeeds, or it fails with an error naming the expected shape. A miss or a bare validation failure on a variant that maps one-to-one onto a valid value is a `ux` finding. Probe **values** — variants of the argument *key* name, and a JSON-stringified array or object or an integer sent for a string as a value, are handled by the framework, not the server. |
406
406
  | Batch / bulk input (arrays of IDs, multi-item ops) | Partial success: mix valid + invalid items |
407
407
  | `annotations.readOnlyHint: true` | Confirm no mutation happened |
408
408
  | `annotations.idempotentHint: true` | Call twice with same input — safe? |
@@ -500,7 +500,7 @@ End with:
500
500
 
501
501
  ## Checklist
502
502
 
503
- - [ ] Stdio boot check completed — `bun run rebuild && bun run start:stdio < /dev/null` shows clean startup (banner, expected counts, no errors) and a graceful shutdown on EOF
503
+ - [ ] Stdio boot check completed — `bun run rebuild && bun run start:stdio < /dev/null` shows clean startup (every expected definition listed in the `Core services constructed` record's `tools` / `resources` / `prompts` fields, no errors) and a graceful shutdown on EOF
504
504
  - [ ] HTTP server built and started; real port parsed from log
505
505
  - [ ] Session initialized (a stateless server returns an empty `sid` — still a pass); `notifications/initialized` sent; negotiated protocol version matches the requested one (a downgrade is a finding)
506
506
  - [ ] Catalog surfaced and presented; descriptions audited for leaks (implementation details, meta-coaching, consumer-aware phrasing)
@@ -4,7 +4,7 @@ description: >
4
4
  Land working-tree changes as logical commits — the work grouped by concern, topped by a release commit (version bump, changelog, regenerated artifacts). The work commits land first, then the version bump, verification, and the release commit on top. Stops at "committed locally on main" — or, when the project releases through a release PR, at "release branch pushed, PR open". No tag, no push to main, no publish: the release-and-publish skill merges, tags, and ships from here. Distilled from the git_wrapup_instructions protocol.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.25"
7
+ version: "1.27"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -105,7 +105,9 @@ git commit --only <paths-for-this-concern> -m "<subject>" -m "<body>"
105
105
 
106
106
  **The file is the atomic boundary:** NEVER split a single file's working-tree changes across commits, regardless of mechanism — not `git add -p`, not an index-only patch (`git apply --cached`), not editing the file between commits to remove-then-re-add a hunk. When one file serves two concerns, it ships whole in the commit of its dominant concern; a later commit may touch the file again only for changes made AFTER the first commit (the version bump applied in step 4).
107
107
 
108
- **Every commit builds on its own.** When a concern changes an exported contract — a service method's return type, a shared helper's signature — the files that consume it ride in the same commit, even when they also carry other concerns. Grouping the contract change into one commit and each consumer into its own later commit leaves pushed commits that fail typecheck alone, and pushed history is never rewritten to repair them.
108
+ **Every commit builds and passes its tests on its own.** When a concern changes an exported contract — a service method's return type, a shared helper's signature, a renamed export, a changed query or behavior a consumer's tests assert — the files that consume it AND their tests ride in the same commit, even when they also carry other concerns. Grouping the contract change into one commit and each consumer into its own later commit leaves pushed commits that fail typecheck or the suite alone, and pushed history is never rewritten to repair them. A snapshot can typecheck and still be red: before pushing, check out each work commit's tree (`git stash` is not the tool — extract it with `git archive <sha> | tar -x -C <scratch>`, symlink the project's `node_modules` into it) and run the test script there; merge groups whose snapshot fails.
109
+
110
+ **A dependency bump lands before the commits that use it.** When any later commit in the stack uses something the new versions introduce — a new framework export, a new `tool()` option, a changed signature — `chore(deps)` is the first work commit. It builds on its own: `package.json`, the lockfile, and any source change the upgrade itself forces (a renamed import, a removed option) ride in it, so the commits above it compile against the versions they were written for. Ordered the other way, the adopting commit and every commit up to the bump fail typecheck at their own SHA.
109
111
 
110
112
  **Subject format:** Conventional Commits, no version in the subject — `feat: hosted server endpoint`, `fix: handle empty SPARQL result sets`, `feat(linter): enrichment contract rules`, `docs: document the enrichment block`, `chore(deps): refresh dev dependencies`.
111
113
 
@@ -142,8 +144,8 @@ When every concern is committed, `git status` is clean. That clean tree is what
142
144
  Every file that declares a version must be updated. Skip any file that doesn't exist in the project. For `@cyanheads/mcp-ts-core` projects:
143
145
 
144
146
  - `package.json` — `version`
145
- - `server.json` — top-level `version` AND every `packages[].version` entry
146
- - `manifest.json` (if present) — `version`. Verify `name` is the bare package name (e.g. `bls-mcp-server`, not `@cyanheads/bls-mcp-server`)
147
+ - `server.json` — top-level `version` AND every `packages[].version` entry. `lint:mcp` flags a mismatch at either level
148
+ - `manifest.json` (if present) — `version`. Packaging validation fails on a mismatch, and on a scoped `name` (use `bls-mcp-server`, not `@cyanheads/bls-mcp-server`)
147
149
  - `.claude-plugin/plugin.json` and `.codex-plugin/plugin.json` (if present) — `version`. Packaging validation fails on a mismatch; `.codex-plugin/mcp.json` is connection config and carries none
148
150
  - `README.md` — version badge. Packaging validation fails on a mismatch with `package.json`; a literal `-` in a prerelease is escaped as `--` (`Version-0.14.0--rc.1-`)
149
151
  - `CLAUDE.md` / `AGENTS.md` — if they pin a version string
@@ -192,15 +194,16 @@ Both scripts are idempotent — safe to run even if nothing changed.
192
194
 
193
195
  ### 7. Run the verification gate
194
196
 
195
- The stack being shipped must pass verification. Both must succeed:
197
+ The stack being shipped must pass verification. All must succeed:
196
198
 
197
199
  ```bash
198
200
  bun run devcheck
201
+ bun run rebuild
199
202
  bun run test:all # or `bun run test` if no test:all script exists
200
203
  bun run test:package # only if the script exists — NOT part of test:all
201
204
  ```
202
205
 
203
- **If either fails, halt.** Do not bypass verification to land the release commit.
206
+ **If any fails, halt.** Do not bypass verification to land the release commit.
204
207
 
205
208
  The work is already committed by this point, so the fix is a new commit on top of the stack, under step 3's conventions — never `git commit --amend`, never a rebase, reset, or any other rewrite of a commit the stack already carries. Land the fix, then re-run this step. The same holds when the gate passes but leaves the tree dirty: `devcheck` auto-fixes as it runs, and a formatter fix to a file committed in step 3 is a follow-up commit of its own, not something to fold into the release commit.
206
209
 
@@ -296,17 +299,19 @@ If the working tree isn't clean or the release commit isn't at HEAD, something w
296
299
 
297
300
  - [ ] Diff reviewed end-to-end before the first commit
298
301
  - [ ] Work concerns committed before the version bump — a version-bearing file a work concern also touches ships whole in that concern's commit, so the release commit brings it the version hunk alone
299
- - [ ] Version bumped in every declaring file (`package.json`, `server.json`, `manifest.json`, `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, README badge, `CLAUDE.md`/`AGENTS.md` if they pin a version) — verify by command, not by eye: `v=$(jq -r .version package.json); grep -rl "$v" package.json server.json manifest.json .claude-plugin/plugin.json .codex-plugin/plugin.json README.md | wc -l` must equal the count of files that exist, and `grep -c "Version-$v-" README.md` must print `1`. `lint:packaging` checks the README badge against `package.json`, so a stale badge now fails `devcheck` instead of shipping unnoticed — the grep still catches a badge written in a shape the check skips
302
+ - [ ] Version bumped in every declaring file (`package.json`, `server.json`, `manifest.json`, `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, README badge, `CLAUDE.md`/`AGENTS.md` if they pin a version) — `devcheck` flags a mismatch in `server.json` (both levels), `manifest.json`, both plugin manifests, and the README badge; step 4's straggler grep covers the docs and Dockerfile labels
300
303
  - [ ] GH issues addressed by this work commented with what landed (if working from GH issues)
301
304
  - [ ] Docs updated for any new or changed features
302
305
  - [ ] Changelog authored at `changelog/<major.minor>.x/<version>.md`
303
306
  - [ ] `CHANGELOG.md` rollup regenerated (`bun run changelog:build`)
304
307
  - [ ] `docs/tree.md` regenerated if structure changed (`bun run tree`)
305
308
  - [ ] `bun run devcheck` passes
309
+ - [ ] `bun run rebuild` succeeds
306
310
  - [ ] `bun run test:all` (or `test`) passes
307
311
  - [ ] `bun run test:package` passes, when the project defines it — it guards the public-export manifest and `test:all` does not run it
308
312
  - [ ] Release PR mode: stack committed on `release/<version>`, never on `main`
309
313
  - [ ] Work grouped into logical commits (large features split by layer); release artifacts (version + changelog + tree) committed separately on top, subject leading with the version
314
+ - [ ] `chore(deps)` is the first work commit whenever a later commit uses what the new versions introduce, and it builds on its own — carrying `package.json`, the lockfile, and any source change the upgrade forces
310
315
  - [ ] A gate failure after the work is committed landed as a new commit on the stack — nothing amended, rebased, or otherwise rewritten
311
316
  - [ ] Every commit carries a body, and every body is one or two lines — none subject-only, none a paragraph
312
317
  - [ ] Release PR mode: branch pushed, PR open — title = release commit subject; body = theme line, `## Changes` in tag rules, `## Gates`, changelog link last (via `--body-file`, no closing keywords)
@@ -4,7 +4,7 @@ description: >
4
4
  Investigate, adopt, and verify dependency updates — with special handling for `@cyanheads/mcp-ts-core`. Captures what changed, understands why, cross-references against the codebase, adopts framework improvements, syncs project skills, and runs final checks. Supports two entry modes: run the full flow end-to-end, or review updates you already applied.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.9"
7
+ version: "2.10"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -177,7 +177,7 @@ The consumer opted into the framework; its templates, skills, scripts, linter ru
177
177
  - **Deprecations** — migrate now, while context is fresh.
178
178
  - **New linter rules** — if the rule now flags existing code, fix the code; don't silence the rule.
179
179
  - **New utilities that supersede local code** — swap them in. The point of the framework is to centralize. This applies even when the local helper has richer messages or branch handling — port the domain detail onto the framework path; don't leave the local helper as-is. (E.g., `httpErrorFromResponse` replacing a project-local `throwForStatus`: keep the per-route message map, but route it through the framework utility.)
180
- - **New conventions** (template changes, new config keys, renamed env vars) — adopt and update `.env.example`, server config schema, `server.json`, and README if user-facing.
180
+ - **New conventions** (template changes, new config keys, renamed env vars) — adopt and update `.env.example`, server config schema, `server.json`, and README if user-facing. A new Bun pin (the template's `packageManager`) moves three places together: `package.json` `packageManager`, the Dockerfile `oven/bun` base tags, and the README Bun badge.
181
181
  - **New patterns that match existing surfaces** — refactor *every* matching site in this pass. Examples: typed error contracts (`errors[]` + `ctx.fail`) on tools that already throw domain-specific failures; factory adoption (`notFound()`, `validationError()`, …) replacing ad-hoc `new McpError(...)`; new logging/observability hooks supplanting bespoke logging. If the framework added a pattern that fits N tools/services, do all N — partial adoption fragments the surface and rots faster.
182
182
  - **New framework features that don't match existing use cases** — skip. These are for future features, not retroactive refactors. "Don't match" means *the surface doesn't exist in this server* (e.g., a new Speech API in a non-speech server) — not "I'd have to touch a few files."
183
183