@cyanheads/mcp-ts-core 0.13.8 → 0.13.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/AGENTS.md +51 -24
  2. package/CLAUDE.md +51 -24
  3. package/README.md +10 -10
  4. package/changelog/0.13.x/0.13.10.md +118 -0
  5. package/changelog/0.13.x/0.13.9.md +113 -0
  6. package/dist/config/index.d.ts +3 -0
  7. package/dist/config/index.d.ts.map +1 -1
  8. package/dist/config/index.js +31 -9
  9. package/dist/config/index.js.map +1 -1
  10. package/dist/core/app.d.ts +6 -3
  11. package/dist/core/app.d.ts.map +1 -1
  12. package/dist/core/app.js +21 -5
  13. package/dist/core/app.js.map +1 -1
  14. package/dist/core/context.d.ts +114 -21
  15. package/dist/core/context.d.ts.map +1 -1
  16. package/dist/core/context.js +40 -0
  17. package/dist/core/context.js.map +1 -1
  18. package/dist/core/index.d.ts +1 -1
  19. package/dist/core/index.d.ts.map +1 -1
  20. package/dist/core/index.js.map +1 -1
  21. package/dist/core/serverManifest.d.ts +6 -0
  22. package/dist/core/serverManifest.d.ts.map +1 -1
  23. package/dist/core/serverManifest.js +6 -0
  24. package/dist/core/serverManifest.js.map +1 -1
  25. package/dist/core/worker.d.ts +6 -0
  26. package/dist/core/worker.d.ts.map +1 -1
  27. package/dist/core/worker.js +1 -0
  28. package/dist/core/worker.js.map +1 -1
  29. package/dist/linter/rules/error-contract-rules.d.ts +3 -44
  30. package/dist/linter/rules/error-contract-rules.d.ts.map +1 -1
  31. package/dist/linter/rules/error-contract-rules.js +8 -144
  32. package/dist/linter/rules/error-contract-rules.js.map +1 -1
  33. package/dist/linter/rules/index.d.ts +1 -1
  34. package/dist/linter/rules/index.d.ts.map +1 -1
  35. package/dist/linter/rules/index.js +1 -1
  36. package/dist/linter/rules/index.js.map +1 -1
  37. package/dist/linter/rules/resource-rules.d.ts.map +1 -1
  38. package/dist/linter/rules/resource-rules.js +1 -2
  39. package/dist/linter/rules/resource-rules.js.map +1 -1
  40. package/dist/linter/rules/tool-rules.d.ts +2 -1
  41. package/dist/linter/rules/tool-rules.d.ts.map +1 -1
  42. package/dist/linter/rules/tool-rules.js +37 -3
  43. package/dist/linter/rules/tool-rules.js.map +1 -1
  44. package/dist/mcp-server/handlerContext.d.ts +26 -13
  45. package/dist/mcp-server/handlerContext.d.ts.map +1 -1
  46. package/dist/mcp-server/handlerContext.js +32 -17
  47. package/dist/mcp-server/handlerContext.js.map +1 -1
  48. package/dist/mcp-server/inputRequired.d.ts +133 -12
  49. package/dist/mcp-server/inputRequired.d.ts.map +1 -1
  50. package/dist/mcp-server/inputRequired.js +192 -20
  51. package/dist/mcp-server/inputRequired.js.map +1 -1
  52. package/dist/mcp-server/prompts/prompt-registration.d.ts +10 -2
  53. package/dist/mcp-server/prompts/prompt-registration.d.ts.map +1 -1
  54. package/dist/mcp-server/prompts/prompt-registration.js +49 -11
  55. package/dist/mcp-server/prompts/prompt-registration.js.map +1 -1
  56. package/dist/mcp-server/resources/resource-registration.d.ts +4 -2
  57. package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
  58. package/dist/mcp-server/resources/resource-registration.js +6 -4
  59. package/dist/mcp-server/resources/resource-registration.js.map +1 -1
  60. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts +4 -3
  61. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
  62. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +42 -14
  63. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
  64. package/dist/mcp-server/server.d.ts +9 -0
  65. package/dist/mcp-server/server.d.ts.map +1 -1
  66. package/dist/mcp-server/server.js +14 -13
  67. package/dist/mcp-server/server.js.map +1 -1
  68. package/dist/mcp-server/tools/tool-registration.d.ts +7 -3
  69. package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
  70. package/dist/mcp-server/tools/tool-registration.js +9 -5
  71. package/dist/mcp-server/tools/tool-registration.js.map +1 -1
  72. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts +163 -40
  73. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts.map +1 -1
  74. package/dist/mcp-server/tools/utils/inputPrevalidation.js +330 -114
  75. package/dist/mcp-server/tools/utils/inputPrevalidation.js.map +1 -1
  76. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +40 -19
  77. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  78. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +387 -130
  79. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  80. package/dist/mcp-server/transports/http/httpErrorHandler.d.ts.map +1 -1
  81. package/dist/mcp-server/transports/http/httpErrorHandler.js +7 -1
  82. package/dist/mcp-server/transports/http/httpErrorHandler.js.map +1 -1
  83. package/dist/mcp-server/transports/http/httpTransport.d.ts.map +1 -1
  84. package/dist/mcp-server/transports/http/httpTransport.js +65 -9
  85. package/dist/mcp-server/transports/http/httpTransport.js.map +1 -1
  86. package/dist/mcp-server/transports/stdio/stdioTransport.d.ts +9 -5
  87. package/dist/mcp-server/transports/stdio/stdioTransport.d.ts.map +1 -1
  88. package/dist/mcp-server/transports/stdio/stdioTransport.js +9 -5
  89. package/dist/mcp-server/transports/stdio/stdioTransport.js.map +1 -1
  90. package/dist/services/canvas/core/CanvasRegistry.d.ts +6 -2
  91. package/dist/services/canvas/core/CanvasRegistry.d.ts.map +1 -1
  92. package/dist/services/canvas/core/CanvasRegistry.js +7 -3
  93. package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
  94. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts +82 -18
  95. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts.map +1 -1
  96. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js +620 -328
  97. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js.map +1 -1
  98. package/dist/services/canvas/providers/duckdb/exportWriter.d.ts +11 -7
  99. package/dist/services/canvas/providers/duckdb/exportWriter.d.ts.map +1 -1
  100. package/dist/services/canvas/providers/duckdb/exportWriter.js +19 -16
  101. package/dist/services/canvas/providers/duckdb/exportWriter.js.map +1 -1
  102. package/dist/services/mirror/core/defineMirror.d.ts +1 -0
  103. package/dist/services/mirror/core/defineMirror.d.ts.map +1 -1
  104. package/dist/services/mirror/core/defineMirror.js +1 -0
  105. package/dist/services/mirror/core/defineMirror.js.map +1 -1
  106. package/dist/testing/index.d.ts +17 -2
  107. package/dist/testing/index.d.ts.map +1 -1
  108. package/dist/testing/index.js +21 -7
  109. package/dist/testing/index.js.map +1 -1
  110. package/dist/types-global/errors.d.ts +18 -15
  111. package/dist/types-global/errors.d.ts.map +1 -1
  112. package/dist/utils/index.d.ts +1 -1
  113. package/dist/utils/index.d.ts.map +1 -1
  114. package/dist/utils/index.js.map +1 -1
  115. package/dist/utils/internal/error-handler/errorHandler.d.ts +5 -4
  116. package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
  117. package/dist/utils/internal/error-handler/errorHandler.js +9 -7
  118. package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
  119. package/dist/utils/internal/error-handler/types.d.ts +3 -1
  120. package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
  121. package/dist/utils/internal/performance.d.ts +4 -2
  122. package/dist/utils/internal/performance.d.ts.map +1 -1
  123. package/dist/utils/internal/performance.js +8 -6
  124. package/dist/utils/internal/performance.js.map +1 -1
  125. package/dist/utils/internal/telemetryMessages.d.ts +0 -1
  126. package/dist/utils/internal/telemetryMessages.d.ts.map +1 -1
  127. package/dist/utils/internal/telemetryMessages.js +0 -1
  128. package/dist/utils/internal/telemetryMessages.js.map +1 -1
  129. package/dist/utils/network/pacer.d.ts +38 -5
  130. package/dist/utils/network/pacer.d.ts.map +1 -1
  131. package/dist/utils/network/pacer.js +87 -25
  132. package/dist/utils/network/pacer.js.map +1 -1
  133. package/dist/utils/telemetry/attributes.d.ts +10 -5
  134. package/dist/utils/telemetry/attributes.d.ts.map +1 -1
  135. package/dist/utils/telemetry/attributes.js +10 -5
  136. package/dist/utils/telemetry/attributes.js.map +1 -1
  137. package/framework-skills/add-app-tool/SKILL.md +3 -3
  138. package/framework-skills/add-export/SKILL.md +5 -16
  139. package/framework-skills/add-prompt/SKILL.md +7 -3
  140. package/framework-skills/add-resource/SKILL.md +7 -5
  141. package/framework-skills/add-service/SKILL.md +3 -12
  142. package/framework-skills/add-test/SKILL.md +6 -3
  143. package/framework-skills/add-tool/SKILL.md +40 -42
  144. package/framework-skills/api-auth/SKILL.md +2 -2
  145. package/framework-skills/api-canvas/SKILL.md +17 -8
  146. package/framework-skills/api-config/SKILL.md +5 -4
  147. package/framework-skills/api-context/SKILL.md +168 -42
  148. package/framework-skills/api-errors/SKILL.md +48 -51
  149. package/framework-skills/api-linter/SKILL.md +30 -35
  150. package/framework-skills/api-mirror/SKILL.md +2 -1
  151. package/framework-skills/api-telemetry/SKILL.md +14 -10
  152. package/framework-skills/api-testing/SKILL.md +43 -11
  153. package/framework-skills/api-utils/SKILL.md +2 -2
  154. package/framework-skills/api-workers/SKILL.md +3 -1
  155. package/framework-skills/design-mcp-server/SKILL.md +6 -6
  156. package/framework-skills/field-test/SKILL.md +5 -5
  157. package/framework-skills/git-wrapup/SKILL.md +8 -6
  158. package/framework-skills/orchestrations/SKILL.md +7 -6
  159. package/framework-skills/orchestrations/workflows/field-test-fix.md +9 -19
  160. package/framework-skills/orchestrations/workflows/fix-wrapup-release.md +7 -7
  161. package/framework-skills/orchestrations/workflows/greenfield-build.md +8 -5
  162. package/framework-skills/orchestrations/workflows/maintenance-release.md +8 -8
  163. package/framework-skills/polish-docs-meta/SKILL.md +4 -4
  164. package/framework-skills/release-and-publish/SKILL.md +8 -6
  165. package/framework-skills/release-pr-review/SKILL.md +38 -24
  166. package/framework-skills/report-issue-framework/SKILL.md +7 -5
  167. package/framework-skills/report-issue-local/SKILL.md +8 -6
  168. package/framework-skills/security-pass/SKILL.md +14 -13
  169. package/package.json +6 -5
  170. package/scripts/devcheck.ts +7 -6
  171. package/scripts/install-otel.ts +84 -0
  172. package/scripts/lint-mcp.ts +87 -27
  173. package/scripts/lint-packaging.ts +226 -4
  174. package/scripts/release-github.ts +117 -5
  175. package/templates/.env.example +2 -0
  176. package/templates/AGENTS.md +5 -4
  177. package/templates/CLAUDE.md +5 -4
  178. package/templates/Dockerfile +67 -50
  179. package/templates/_.mcpbignore +2 -0
  180. package/templates/src/mcp-server/tools/definitions/echo.tool.ts +3 -8
package/AGENTS.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Package:** `@cyanheads/mcp-ts-core`
4
- **Version:** 0.13.8
4
+ **Version:** 0.13.10
5
5
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
6
- **MCP SDK:** `@modelcontextprotocol/server` ^2.0.0 (protocol revisions 2026-07-28 and 2025-*)
6
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.1.0 (protocol revisions 2026-07-28 and 2025-*)
7
7
  **Zod:** ^4.6.5
8
8
  **GitHub:** [cyanheads/mcp-ts-core](https://github.com/cyanheads/mcp-ts-core)
9
9
  **npm:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core)
@@ -45,7 +45,7 @@ Both paths share the same public API. Init copies starter `package.json`, config
45
45
 
46
46
  | Subpath | Key Exports | Purpose |
47
47
  |:--------|:------------|:--------|
48
- | `@cyanheads/mcp-ts-core` | `createApp`, `tool`, `resource`, `prompt`, `appTool`, `appResource`, `APP_RESOURCE_MIME_TYPE`, `headerParam`, `Context`, `createFail`, `createRecoveryFor`, `TypedFail`, `TypedRecoveryFor`, `ReasonOf`, `HandlerContext`, `Enrich`, `EnrichHelpers`, `TypedEnrich`, `ContentCollect`, `ContentBlock`, `z`, `inputRequired`, `completable`, `isCompletable`, `CompleteCallback`, `CompleteResourceTemplateCallback`, `CacheHint`, `CacheHints`, `CacheScope`, `SessionMode`, `ResolvedSessionMode` | Main entry point |
48
+ | `@cyanheads/mcp-ts-core` | `createApp`, `tool`, `resource`, `prompt`, `appTool`, `appResource`, `APP_RESOURCE_MIME_TYPE`, `headerParam`, `Context`, `ClientCapabilities`, `createFail`, `createRecoveryFor`, `TypedFail`, `TypedRecoveryFor`, `ReasonOf`, `HandlerContext`, `Enrich`, `EnrichHelpers`, `TypedEnrich`, `ContentCollect`, `ContentBlock`, `z`, `inputRequired`, `completable`, `isCompletable`, `CompleteCallback`, `CompleteResourceTemplateCallback`, `CacheHint`, `CacheHints`, `CacheScope`, `SessionMode`, `ResolvedSessionMode` | Main entry point |
49
49
  | `/worker` | `createWorkerHandler`, `CloudflareBindings` | Cloudflare Workers entry |
50
50
  | `/tools` | `ToolDefinition`, `AnyToolDefinition`, `ToolAnnotations` | Tool definition types |
51
51
  | `/resources` | `ResourceDefinition`, `AnyResourceDefinition` | Resource definition types |
@@ -100,9 +100,8 @@ await createApp({
100
100
  tools: allToolDefinitions,
101
101
  resources: allResourceDefinitions,
102
102
  prompts: allPromptDefinitions,
103
- instructions: // server-level orientation, sent on every initialize
104
- 'Pre-configured shortcuts:\n- `default` → production API\n' +
105
- 'Other endpoints reachable via `connect({ baseUrl })`.',
103
+ instructions: // server-level orientation, sent on every initialize — one literal, a few sentences
104
+ 'Calls reach the production API by default. Pass `baseUrl` to `connect` to reach another endpoint.',
106
105
  extensions: { // SEP-2133 extensions advertised in capabilities
107
106
  'vendor/my-extension': { /* extension config */ },
108
107
  },
@@ -168,7 +167,7 @@ interface ServerHandle {
168
167
  }
169
168
  ```
170
169
 
171
- **Exit contract.** `shutdown()` is exit-free and unbounded: it also serves the startup-failure rollback and direct calls from embedders and tests, so ending the process belongs to the handlers. `SIGTERM`, `SIGINT`, and stdin EOF each run that same shutdown and then exit explicitly. A signal exits 0 once the shutdown settles and 1 when the 10 s ceiling fires, after a warning naming the step that never settled; stdin EOF exits 0 either way, unchanged. The ceiling bounds the shutdown as a whole rather than any single await, so a step that settles inside it is never truncated. A second signal mid-shutdown reaches no handler (shutdown detaches them as it starts) and terminates on the OS default, 143 / 130 — the operator's force-kill escape hatch. `uncaughtException` / `unhandledRejection` exit 1.
170
+ **Exit contract.** `shutdown()` is exit-free and unbounded: it also serves the startup-failure rollback and direct calls from embedders and tests, so ending the process belongs to the handlers. `SIGTERM`, `SIGINT`, and stdin EOF each run that same shutdown and then exit explicitly. On stdin EOF the SDK transport has already closed itself, so a request still in flight is aborted (its `ctx.signal` fires) and never answered — the client has hung up. A signal exits 0 once the shutdown settles and 1 when the 10 s ceiling fires, after a warning naming the step that never settled; stdin EOF exits 0 either way, unchanged. The ceiling bounds the shutdown as a whole rather than any single await, so a step that settles inside it is never truncated. A second signal mid-shutdown reaches no handler (shutdown detaches them as it starts) and terminates on the OS default, 143 / 130 — the operator's force-kill escape hatch. `uncaughtException` / `unhandledRejection` exit 1.
172
171
 
173
172
  ---
174
173
 
@@ -238,7 +237,7 @@ export const myTool = tool('my_tool', {
238
237
  });
239
238
  ```
240
239
 
241
- **Steps:** Create `src/mcp-server/tools/definitions/[name].tool.ts` (kebab-case) → use `tool('snake_case', {...})` with Zod `.describe()` on all fields → implement `handler(input, ctx)` (pure, throws on failure) → add `auth`/`format` if needed → register in `definitions/index.ts` → `bun run devcheck` → smoke-test with `bun run rebuild && bun run start:stdio` (or `start:http`).
240
+ **Steps:** Create `src/mcp-server/tools/definitions/[name].tool.ts` (kebab-case) → use `tool('snake_case', {...})` with Zod `.describe()` on all fields → implement `handler(input, ctx)` (pure, throws on failure) → add `auth`/`format` if needed → register in `definitions/index.ts` → `bun run devcheck` → smoke-test with `bun run rebuild && bun run start:stdio < /dev/null` (or `start:http`) and confirm the `Core services constructed` log record lists the tool in its `tools` field (the message text shows only counts).
242
241
 
243
242
  **Schema constraint:** Input/output schemas must use JSON-Schema-serializable Zod types only. The MCP SDK converts schemas to JSON Schema for `tools/list` — non-serializable types (`z.custom()`, `z.date()`, `z.transform()`, `z.bigint()`, `z.symbol()`, `z.void()`, `z.map()`, `z.set()`, `z.function()`, `z.nan()`) cause a hard runtime failure. Use structural equivalents instead (e.g., `z.string()` with `.describe('ISO 8601 date')` instead of `z.date()`). The `schema-serializable` lint rule catches this at build time (`bun run lint:mcp` / `devcheck`).
244
243
 
@@ -255,7 +254,7 @@ export const myTool = tool('my_tool', {
255
254
 
256
255
  **Strict input:** `tool()` stores `input.strict()`, so an unrecognized argument key is rejected by name before the handler runs and `inputSchema` advertises `additionalProperties: false`. Root-level only — a nested `z.object()` still strips unless it is strict itself. An explicit `.passthrough()` / `.catchall()` is honored. Declare `.strict()` **before** `.describe()` / `.meta()` on the root: Zod keys both to the schema instance and `.strict()` clones without it, so a root describe declared after is discarded and never advertised — `lint:mcp` reports that as `schema-root-meta-discarded`.
257
256
 
258
- **Pre-validation:** an ordered step inside `parseToolArguments` rescues calls strict input would otherwise reject — drop client-added root keys (`_meta`, `tool_call_description`, `toolCallId`, any undeclared `_`-prefixed key) → rewrite key aliases (declared `inputAliases`, plus any undeclared key whose case-folded form names exactly one declared key) → parse → on failure, repair a JSON-stringified array and re-parse once, keeping it only if the schema then accepts it. All on by default; nothing changes the advertised `inputSchema`, and a call that still fails throws the identical rejection. A declared key, an author-opened root, and a `headerParam` target are never touched. Server-level switches: `createApp({ input: { ignoreKeys, caseStyleAliases, coerce } })`. Counters: `mcp.input.ignored_key`, `mcp.input.aliased`, `mcp.input.coerced`. Lint: `input-alias-conflict`. See `add-tool` skill.
257
+ **Pre-validation:** an ordered step inside `parseToolArguments` rescues calls strict input would otherwise reject — drop client-added root keys (`_meta`, `tool_call_description`, `toolCallId`, any undeclared `_`-prefixed key) → rewrite key aliases (declared `inputAliases`, plus any undeclared key whose case-folded form names exactly one declared key) → parse → on failure, repair a JSON-stringified array or object, or a safe integer where a string is expected (`8654467` → `"8654467"`), and re-parse once, keeping the repair only if the schema then accepts it. If that still fails and the drop discarded a key, the stages rerun alias-first so `_query` or a declared `_q` alias reaches its target, and the retry (repair included) is kept only if it validates — a call the first order validates resolves exactly as it would without the retry. All on by default, and nothing changes the advertised `inputSchema`. A call that still fails throws the rejection of the last order tried — the retry's when it ran, so a declared `_q` alias with a bad value reports that value's failure and `Validated _q as query.` — the same one it gets under `coerce: false`, reporting that order's rewrites and underscore-rule drops as `data.input` and in the hint. A declared key, an author-opened root, and a `headerParam` target are never touched. Server-level switches: `createApp({ input: { ignoreKeys, caseStyleAliases, coerce } })`. Counters, for the attempt the handler receives: `mcp.input.ignored_key`, `mcp.input.aliased`, `mcp.input.coerced` (once per repair kind). Lint: `input-alias-conflict`. See `add-tool` skill.
259
258
 
260
259
  **Header-mirrored input (2026-07-28):** `headerParam(z.string(), 'Region')` designates an input property with `x-mcp-header`, so its value also rides an `Mcp-Param-Region` request header and an intermediary can read it without parsing the body. Mirroring, not relocation — the handler still reads the argument from the body, and nothing else about the field changes. Only a primitive-typed (`string`/`integer`/`number`/`boolean`) property statically reachable through a chain of `properties` keys qualifies: an array element, a `z.record()` value, and every field of a discriminated-union input root are unreachable, and header names must be RFC 9110 tokens, case-insensitively unique per schema. `tool()` rejects a violation at definition time naming the field path — the SDK only warns, then conforming Streamable HTTP clients drop the tool. Lint rule: `header-param-designation`.
261
260
 
@@ -301,10 +300,11 @@ interface Context {
301
300
  readonly traceId?: string;
302
301
  readonly spanId?: string;
303
302
  readonly auth?: AuthContext;
303
+ readonly clientCapabilities: ClientCapabilities | undefined; // what the client declared; undefined when no view (2025-era stateless HTTP)
304
304
  readonly log: ContextLogger; // auto-correlated: requestId, traceId, tenantId
305
305
  readonly state: ContextState; // tenant-scoped KV storage
306
- readonly requestInput: RequestInputFn; // (spec) => never — suspends and asks the caller for input
307
- readonly inputs: ContextInputs; // reader over a retried request's responses
306
+ readonly requestInput: RequestInputFn; // (spec, options?) => never — suspends and asks the caller for input
307
+ readonly inputs: ContextInputs; // the request's responses, limited to what the client declared
308
308
  readonly notifyPromptListChanged?: (() => void) | undefined; // prompt list changed
309
309
  readonly notifyResourceListChanged?: (() => void) | undefined; // resource list changed
310
310
  readonly notifyResourceUpdated?: ((uri: string) => void) | undefined; // resource content changed
@@ -313,7 +313,7 @@ interface Context {
313
313
  readonly uri?: URL; // present for resource handlers
314
314
  readonly content: ContentCollect; // media blocks → prepended to content[]; never in structuredContent
315
315
  readonly enrich: Enrich; // success-path agent context → structuredContent + content[]; typed on HandlerContext<R, E>
316
- recoveryFor(reason: string): { recovery: { hint: string } } | Record<string, never>; // opt-in contract resolver
316
+ recoveryFor(reason: string): { recovery: { hint: string } } | Record<string, never>; // a declared entry's hint, in wire shape
317
317
  }
318
318
  ```
319
319
 
@@ -368,6 +368,33 @@ useFormat(answer.format);
368
368
  cancelled prompt is terminal, not a round to retry. `inputRequired.elicitUrl({ message, url })`
369
369
  hands the user an external link instead of a form.
370
370
 
371
+ A client can send responses on a call nothing asked for, so only what it declared reaches
372
+ `ctx.inputs`, at the mode level the request would need: an elicit result needs `elicitation` —
373
+ `elicitation.form` when it carries `content` — a sampling result `sampling` (`sampling.tools` when it
374
+ holds a tool block), a roots result `roots`, and a request with no capability view gets none.
375
+ `ctx.clientCapabilities` — the SDK-parsed `initialize` value on 2025-era connections (a bare
376
+ `elicitation: {}` reads back as `{ elicitation: { form: {} } }`), the request's envelope as sent on
377
+ 2026-07-28 — decides whether to *ask* for optional context (request roots only when `roots` is
378
+ declared, else fall through); it is never a reason to skip a consent prompt.
379
+
380
+ A 2025-era client that declared no matching capability is refused as `client_capability_missing`,
381
+ with a hint that ends at reconnecting. When the tool's own arguments can stand in for the answer,
382
+ say so per call — `ctx.requestInput(spec, { fallbackHint: 'Or call again with noun supplied.' })` —
383
+ and the sentence is appended to that hint. A consent gate passes none: it has no such field.
384
+
385
+ **Consent gates redeem a server record.** A capable client can still pre-answer, and any
386
+ `requestState` can be replayed within its lifetime, so a destructive handler first redeems (reads and
387
+ deletes) a `ctx.state` record `{ operation, clientId, subject, target, contentHash }` stored under a
388
+ random id — the id is all `requestState` carries — and asks again on an unknown, used, or expired id,
389
+ or when any field differs from this call. Carrying the target in `requestState` and comparing on
390
+ re-entry is replayable. Redeeming stops a sequential replay, not concurrent retries: until an atomic
391
+ `ctx.state.take` exists (#593), an action that must not repeat is made idempotent per record. The
392
+ record's storage is shared by every instance a 2026-07-28 retry can reach — `filesystem`, `supabase`,
393
+ or `cloudflare-d1`, never `cloudflare-kv`. `MCP_REQUEST_STATE_KEY` (≥ 32 bytes, the same on every
394
+ instance) opts into sealing: the framework signs the string a handler returns, the SDK rejects any
395
+ other as `-32602` `invalid_request_state` before the handler runs, and `ctx.inputs.state()` still
396
+ returns the original string. See `api-context` for the full pattern.
397
+
371
398
  ### `ctx.content`
372
399
 
373
400
  Accumulates non-text content blocks — image/audio bytes, embedded resources, resource links — onto the response: `ctx.content.image(data, mimeType)`, `ctx.content.audio(data, mimeType)`, or `ctx.content(block)` for a raw `ContentBlock`. Blocks are prepended to `content[]` after `format()` runs and never enter `structuredContent`, so a handler can emit media for the calling model without the base64 duplicating into typed output. Always present (no-op when unused); callable from handler and service layer.
@@ -378,7 +405,7 @@ See `api-context` skill for full details.
378
405
 
379
406
  ## Error Handling
380
407
 
381
- **Recommended path: declare a typed error contract.** Add `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` to `tool()` / `resource()`. Handler gets `ctx.fail(reason, msg?, data?)` typed against the reason union — typos fail at compile time. Runtime auto-populates `data.reason` for observability; linter enforces conformance against the handler body. `recovery` is required (≥5 words, lint-validated) — the single source of truth for the wire hint. Spread `ctx.recoveryFor('reason')` into `data` to opt the contract recovery onto the wire (framework mirrors `data.recovery.hint` into `content[]` text); override with explicit `{ recovery: { hint: '...' } }` when runtime context matters. Optional `severity` (`debug`/`info`/`notice`/`warning`) logs a modeled outcome below `error` — tools only, logging only: the wire envelope, the span status, and the `mcp.tool.*` metrics are untouched. Optional `thrownBy: 'service'` marks an entry the service layer produces, so `error-contract-unthrown` skips it while still checking the handler's own reasons — lint-only metadata, nothing at runtime reads it.
408
+ **Recommended path: declare a typed error contract.** Add `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` to `tool()` / `resource()`. Handler gets `ctx.fail(reason, msg?, data?)` typed against the reason union — typos fail at compile time. Runtime auto-populates `data.reason` for observability; linter enforces conformance against the handler body. `recovery` is required (≥5 words, lint-validated) — the wire hint for its reason: when a failure whose `data.reason` names the entry reaches the tool or resource factory without `data.recovery`, the framework fills `data.recovery.hint` from it — a bare `ctx.fail('reason')` and a service throw carrying `{ reason }` alike, matched on the reason alone — and mirrors it into `content[]` text. Override with explicit `{ recovery: { hint: '...' } }` when runtime context matters; a throw-site `recovery` always wins, and the thrown `McpError` itself is never changed. Optional `severity` (`debug`/`info`/`notice`/`warning`) logs a modeled outcome below `error` — tools only, logging only: the wire envelope, the span status, and the `mcp.tool.*` metrics are untouched. The framework's own `invalid_arguments` and `client_capability_missing` refusals log at `notice` unless an entry naming them declares otherwise. Optional `thrownBy: 'service'` marks an entry the service layer produces, so `error-contract-unthrown` skips it while still checking the handler's own reasons — lint-only metadata, nothing at runtime reads it.
382
409
 
383
410
  ```ts
384
411
  errors: [
@@ -390,8 +417,8 @@ errors: [
390
417
  recovery: 'Wait 30 seconds before retrying or reduce batch size.' },
391
418
  ],
392
419
  async handler(input, ctx) {
393
- // Static recovery — pulled from the contract via ctx.recoveryFor.
394
- if (queue.full()) throw ctx.fail('queue_full', undefined, { ...ctx.recoveryFor('queue_full') });
420
+ // Static recovery — the framework fills the contract's hint onto the wire.
421
+ if (queue.full()) throw ctx.fail('queue_full');
395
422
  // Dynamic recovery — interpolate runtime context, override the contract default.
396
423
  if (!matched) throw ctx.fail('no_match', `No data for ${input.pmids.length} PMIDs`, {
397
424
  pmids: input.pmids,
@@ -400,7 +427,7 @@ async handler(input, ctx) {
400
427
  }
401
428
  ```
402
429
 
403
- **`ctx.recoveryFor(reason)`** returns `{}` when no contract exists (spread-safe). Typed against the declared reason union on `HandlerContext<R>`. Works in services: `throw validationError(msg, { reason: 'X', ...ctx.recoveryFor('X') })`. Opt-in — author spreads explicitly.
430
+ **`ctx.recoveryFor(reason)`** returns the entry's hint in wire shape, `{}` when no contract exists (spread-safe). Typed against the declared reason union on `HandlerContext<R>`. Not needed to put a declared hint on the wire — the fill does that — only when the hint must ride the thrown error itself, as in a test of the handler's own throw.
404
431
 
405
432
  **Contracts are inline, per-tool.** Don't extract shared `errors[]` constants — locality is the point, and dynamic `recovery` hints need tool-specific context. Declare domain-specific failures only; **baseline codes** (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) are auto-allowed by conformance lint. The lint scans handler source only — service-layer throws still reach clients via auto-classification.
406
433
 
@@ -418,9 +445,9 @@ For HTTP responses from upstream APIs, use `httpErrorFromResponse(response, { se
418
445
 
419
446
  **Auto-classification.** Plain `Error`, `ZodError`, and any other thrown value are caught and classified automatically. Resolution order: request signal already aborted (→ `RequestCancelled`, outranking the thrown value's own code, `McpError` included) → `McpError` code (preserved as-is) → SDK `ConnectionClosed` (→ `RequestCancelled`) → engine resource-limit `RangeError` by whole message — stack overflow, maximum string size (→ `InternalError`) → JS constructor name (`SyntaxError` → `ValidationError`; `TypeError` is excluded) → provider patterns (HTTP status codes, AWS errors, DB errors) → common message patterns → `AbortError` name (→ `Timeout`) → `InternalError` fallback. A result that breaks the definition's own `output` or `enrichment` schema fails as `InternalError` naming that contract, not `ValidationError`.
420
447
 
421
- **Error-path parity.** Tool errors: `content[]` carries `Error: <message>`, then `Recovery: <hint>` when the hint says something the message does not already contain, then a closing `(reason … · not retryable)` for whichever of `data.reason` / `data.retryable` is present; the numeric code and `data.issues` stay JSON-only. `structuredContent.error` carries `{ code, message, data? }`. No `_meta.error`. Resources re-throw via JSON-RPC error envelope. An argument rejection is one of them: `-32602` with `data.issues`, plus `data.reason: 'invalid_arguments'` and a hint synthesized from the issues and the root schema — never a tool-declared `reason`, since the handler never ran. `client_capability_missing` is the second framework-owned reason: a `ctx.requestInput` return a 2025-era connection cannot serve is refused before any wire traffic as `-32600` carrying that reason and a hint naming the capability.
448
+ **Error-path parity.** Tool errors: `content[]` carries `Error: <message>`, then `Recovery: <hint>` when the hint says something the message does not already contain, then a closing `(reason … · not retryable · request <id>)` for whichever of `data.reason` / `data.retryable` / `data.requestId` is present; the numeric code and `data.issues` stay JSON-only. `structuredContent.error` carries `{ code, message, data? }`. No `_meta.error`. Resources re-throw via JSON-RPC error envelope. An argument rejection is one of them: `-32602` with `data.issues`, plus `data.reason: 'invalid_arguments'` and a hint synthesized from the issues and the root schema — never a tool-declared `reason`, since the handler never ran. When pre-validation rewrote or dropped a key the caller wrote, `data.input` (`{ aliased: [{ alias, target }], ignored }`) names it and the hint closes with `Validated … as ….` / `Dropped undeclared key ….`; an ignore-list drop is never reported. `client_capability_missing` is the second framework-owned reason: a `ctx.requestInput` return a 2025-era connection cannot serve is refused before any wire traffic as `-32600` carrying that reason and a hint naming the capability. **`data.requestId`** is set on every error envelope the framework builds — tool results, resource reads, prompts, `httpErrorHandler`'s JSON-RPC errors — equal to the `requestId` of that call's log records: a generated token, or the client's JSON-RPC id when it is a string (`httpErrorHandler` always generates its own). A resource read refused before it is measured (auth, `params`) carries an id no record shares. It replaces a thrown `data.requestId`, is never added to the thrown `McpError`, and is left off a resource `-32602` whose `data` is exactly `{ uri }` and off `runToolContract` results.
422
449
 
423
- **Lint rules** (all warnings, surfaced in `devcheck`): `prefer-mcp-error-in-handler`, `prefer-error-factory`, `preserve-cause-on-rethrow`, `no-stringify-upstream-error`, `error-contract-conformance`, `error-contract-prefer-fail`, `error-contract-unthrown` (a declared reason no literal `ctx.fail`/`ctx.recoveryFor` in the handler names, unless marked `thrownBy: 'service'`), `error-contract-recovery-unforwarded` (a `ctx.fail` site carrying neither `ctx.recoveryFor('<reason>')` nor its own `recovery` key, so the declared hint reaches neither client surface). See `api-linter` skill.
450
+ **Lint rules** (all warnings, surfaced in `devcheck`): `prefer-mcp-error-in-handler`, `prefer-error-factory`, `preserve-cause-on-rethrow`, `no-stringify-upstream-error`, `error-contract-conformance`, `error-contract-prefer-fail`, `error-contract-unthrown` (a declared reason no literal `ctx.fail`/`ctx.recoveryFor` in the handler names, unless marked `thrownBy: 'service'`). See `api-linter` skill.
424
451
 
425
452
  See `api-errors` skill for the full pattern-matching table, error code reference, and detailed examples.
426
453
 
@@ -454,7 +481,7 @@ Managed by `@cyanheads/mcp-ts-core`. Validated via Zod. Precedence: `createApp()
454
481
  | Category | Key Variables |
455
482
  |:---------|:-------------|
456
483
  | Transport | `MCP_TRANSPORT_TYPE` (`stdio`\|`http`), `MCP_HTTP_PORT`, `MCP_HTTP_HOST`, `MCP_HTTP_ENDPOINT_PATH` |
457
- | Auth | `MCP_AUTH_MODE`, `MCP_AUTH_SECRET_KEY`, `MCP_AUTH_DISABLE_SCOPE_CHECKS`, `OAUTH_*` |
484
+ | Auth | `MCP_AUTH_MODE`, `MCP_AUTH_SECRET_KEY`, `MCP_AUTH_DISABLE_SCOPE_CHECKS`, `OAUTH_*`, `MCP_REQUEST_STATE_KEY` (opt-in `requestState` sealing) |
458
485
  | Storage | `STORAGE_PROVIDER_TYPE` (`in-memory`\|`filesystem`\|`supabase`\|`cloudflare-r2`\|`cloudflare-kv`\|`cloudflare-d1`) |
459
486
  | LLM | `OPENROUTER_API_KEY`, `OPENROUTER_APP_URL/NAME`, `LLM_DEFAULT_*` |
460
487
  | Telemetry | `OTEL_ENABLED`, `OTEL_SERVICE_NAME/VERSION`, `OTEL_EXPORTER_OTLP_*` |
@@ -481,7 +508,7 @@ describe('myTool', () => {
481
508
  });
482
509
  ```
483
510
 
484
- **`createMockContext` options:** `createMockContext()` (state included), `{ tenantId: 'test-tenant' }` (explicit tenant; defaults to `'default'`, as stdio resolves it), `{ errors: myTool.errors }` (typed `ctx.fail`), `{ inputResponses }` / `{ requestState }` (seed `ctx.inputs` to drive a multi-round-trip handler's second round directly), plus `auth`, `sessionId`, `signal`, `requestId`, `uri`, and the four `notify*` callbacks.
511
+ **`createMockContext` options:** `createMockContext()` (state included), `{ tenantId: 'test-tenant' }` (explicit tenant; defaults to `'default'`, as stdio resolves it), `{ errors: myTool.errors }` (typed `ctx.fail`), `{ inputResponses }` / `{ requestState }` (seed `ctx.inputs` to drive a multi-round-trip handler's second round directly), `{ clientCapabilities }` (seeds `ctx.clientCapabilities` and filters the seeded responses to the declared kinds, as production does — omitted, nothing is filtered), plus `auth`, `sessionId`, `signal`, `requestId`, `uri`, and the four `notify*` callbacks.
485
512
 
486
513
  **`ctx.state` in tests is the production path.** The mock backs it with a real `StorageService` over an `InMemoryProvider`, so key validation (`[a-zA-Z0-9_.\-/]+` — colons rejected), the JSON round-trip of every value, and TTL expiry behave exactly as they do in a deployment. Passing `errors` narrows the return type to `HandlerContext<ReasonOf<…>>`, which is what a definition declaring a contract types its handler's `ctx` as — so `definition.handler(input, ctx)` typechecks.
487
514
 
@@ -491,7 +518,7 @@ describe('myTool', () => {
491
518
 
492
519
  **Fixture-based tests:** `mcpTest` from `/testing/vitest` (optional peer `vitest`) extends Vitest's `test` with per-test fixtures: `ctx` (fresh mock context), `session` (session-bound context), `fetchMock` (strict fetch fake, installed/restored around the test), and `storage` (fresh in-memory `StorageService`). Override fixtures via `.extend` with the function form only — a bare value would share one mutable context across every test in the file.
493
520
 
494
- **Tool contracts:** `runToolContract(definition, input)` from `/testing` validates input/output, invokes the handler, formats content, and returns production-shaped success/error surfaces without transport auth or telemetry. `toolContractSuite(definition, { success, errors })` from `/testing/vitest` registers reusable schema/handler/error-envelope conformance cases for a server's own tool definitions.
521
+ **Tool contracts:** `runToolContract(definition, input)` from `/testing` validates input/output, invokes the handler, formats content, and returns production-shaped success/error surfaces — the declared `recovery` fill included — without transport auth, telemetry, or `data.requestId`. `toolContractSuite(definition, { success, errors })` from `/testing/vitest` registers reusable schema/handler/error-envelope conformance cases for a server's own tool definitions.
495
522
 
496
523
  **Fuzz testing:** `fuzzTool`/`fuzzResource`/`fuzzPrompt` from `/testing/fuzz` generate valid and adversarial inputs from Zod schemas via `fast-check`, then assert handler invariants (no crashes, no prototype pollution, no stack trace leaks). Returns a `FuzzReport` for custom assertions.
497
524
 
@@ -540,10 +567,10 @@ Skills live in `framework-skills/<name>/SKILL.md`; the full list is discoverable
540
567
  - **Auth:** via `auth: ['scope']` on definitions (not HOF wrapper)
541
568
  - **Missing input:** read `ctx.inputs` first, then `return ctx.requestInput(...)`
542
569
  - **Pagination:** large resource lists use `extractCursor`/`paginateArray`
543
- - **Registration:** definitions exported in `definitions/index.ts` barrel
570
+ - **Registration:** definitions collected in the `definitions/index.ts` barrel's array passed to `createApp()` — an `export` line alone registers nothing
544
571
  - **Tests:** `createMockContext()`, `.handler()` tested directly
545
572
  - **Gate:** `bun run devcheck` passes (includes MCP definition linting)
546
- - **Smoke-test:** `bun run rebuild && bun run start:stdio` (or `start:http`)
573
+ - **Smoke-test:** `bun run rebuild && bun run start:stdio < /dev/null` (or `start:http`); the `Core services constructed` log record lists every definition in its `tools` / `resources` / `prompts` fields
547
574
 
548
575
  ---
549
576
 
@@ -566,7 +593,7 @@ Skills live in `framework-skills/<name>/SKILL.md`; the full list is discoverable
566
593
  | `bun run test:leak-gate` | The retention gate's own sentinel suite. Each case spawns a full Vitest run, so it is excluded from the `unit` project |
567
594
  | `bun run test:coverage` | Root projects with coverage thresholds enforced |
568
595
  | `bun run test:integration` | Real server subprocesses over stdio and HTTP |
569
- | `bun run test:worker` | The framework under real `workerd`, then a standalone Worker bundle through the Wrangler toolchain — real Node on both legs |
596
+ | `bun run test:worker` | The framework under real `workerd`, then a standalone Worker bundle through the Wrangler toolchain — real Node on both legs. The `workerd` leg enforces its own coverage thresholds over the Worker entry and Cloudflare storage providers, reported to `reports/coverage-worker/` |
570
597
  | `bun run test:package` | Rebuilds, packs the tarball, and consumes it as an external project would (exports, declarations, both runtimes) |
571
598
  | `bun run test:node` | Root projects + integration under real Node via `scripts/with-node.ts`, which bypasses Bun's `node` PATH shim |
572
599
  | `bun run test:order` | Root projects on real Node in shuffled file order under a pinned seed — catches inter-file state leakage |
package/CLAUDE.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Package:** `@cyanheads/mcp-ts-core`
4
- **Version:** 0.13.8
4
+ **Version:** 0.13.10
5
5
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
6
- **MCP SDK:** `@modelcontextprotocol/server` ^2.0.0 (protocol revisions 2026-07-28 and 2025-*)
6
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.1.0 (protocol revisions 2026-07-28 and 2025-*)
7
7
  **Zod:** ^4.6.5
8
8
  **GitHub:** [cyanheads/mcp-ts-core](https://github.com/cyanheads/mcp-ts-core)
9
9
  **npm:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core)
@@ -45,7 +45,7 @@ Both paths share the same public API. Init copies starter `package.json`, config
45
45
 
46
46
  | Subpath | Key Exports | Purpose |
47
47
  |:--------|:------------|:--------|
48
- | `@cyanheads/mcp-ts-core` | `createApp`, `tool`, `resource`, `prompt`, `appTool`, `appResource`, `APP_RESOURCE_MIME_TYPE`, `headerParam`, `Context`, `createFail`, `createRecoveryFor`, `TypedFail`, `TypedRecoveryFor`, `ReasonOf`, `HandlerContext`, `Enrich`, `EnrichHelpers`, `TypedEnrich`, `ContentCollect`, `ContentBlock`, `z`, `inputRequired`, `completable`, `isCompletable`, `CompleteCallback`, `CompleteResourceTemplateCallback`, `CacheHint`, `CacheHints`, `CacheScope`, `SessionMode`, `ResolvedSessionMode` | Main entry point |
48
+ | `@cyanheads/mcp-ts-core` | `createApp`, `tool`, `resource`, `prompt`, `appTool`, `appResource`, `APP_RESOURCE_MIME_TYPE`, `headerParam`, `Context`, `ClientCapabilities`, `createFail`, `createRecoveryFor`, `TypedFail`, `TypedRecoveryFor`, `ReasonOf`, `HandlerContext`, `Enrich`, `EnrichHelpers`, `TypedEnrich`, `ContentCollect`, `ContentBlock`, `z`, `inputRequired`, `completable`, `isCompletable`, `CompleteCallback`, `CompleteResourceTemplateCallback`, `CacheHint`, `CacheHints`, `CacheScope`, `SessionMode`, `ResolvedSessionMode` | Main entry point |
49
49
  | `/worker` | `createWorkerHandler`, `CloudflareBindings` | Cloudflare Workers entry |
50
50
  | `/tools` | `ToolDefinition`, `AnyToolDefinition`, `ToolAnnotations` | Tool definition types |
51
51
  | `/resources` | `ResourceDefinition`, `AnyResourceDefinition` | Resource definition types |
@@ -100,9 +100,8 @@ await createApp({
100
100
  tools: allToolDefinitions,
101
101
  resources: allResourceDefinitions,
102
102
  prompts: allPromptDefinitions,
103
- instructions: // server-level orientation, sent on every initialize
104
- 'Pre-configured shortcuts:\n- `default` → production API\n' +
105
- 'Other endpoints reachable via `connect({ baseUrl })`.',
103
+ instructions: // server-level orientation, sent on every initialize — one literal, a few sentences
104
+ 'Calls reach the production API by default. Pass `baseUrl` to `connect` to reach another endpoint.',
106
105
  extensions: { // SEP-2133 extensions advertised in capabilities
107
106
  'vendor/my-extension': { /* extension config */ },
108
107
  },
@@ -168,7 +167,7 @@ interface ServerHandle {
168
167
  }
169
168
  ```
170
169
 
171
- **Exit contract.** `shutdown()` is exit-free and unbounded: it also serves the startup-failure rollback and direct calls from embedders and tests, so ending the process belongs to the handlers. `SIGTERM`, `SIGINT`, and stdin EOF each run that same shutdown and then exit explicitly. A signal exits 0 once the shutdown settles and 1 when the 10 s ceiling fires, after a warning naming the step that never settled; stdin EOF exits 0 either way, unchanged. The ceiling bounds the shutdown as a whole rather than any single await, so a step that settles inside it is never truncated. A second signal mid-shutdown reaches no handler (shutdown detaches them as it starts) and terminates on the OS default, 143 / 130 — the operator's force-kill escape hatch. `uncaughtException` / `unhandledRejection` exit 1.
170
+ **Exit contract.** `shutdown()` is exit-free and unbounded: it also serves the startup-failure rollback and direct calls from embedders and tests, so ending the process belongs to the handlers. `SIGTERM`, `SIGINT`, and stdin EOF each run that same shutdown and then exit explicitly. On stdin EOF the SDK transport has already closed itself, so a request still in flight is aborted (its `ctx.signal` fires) and never answered — the client has hung up. A signal exits 0 once the shutdown settles and 1 when the 10 s ceiling fires, after a warning naming the step that never settled; stdin EOF exits 0 either way, unchanged. The ceiling bounds the shutdown as a whole rather than any single await, so a step that settles inside it is never truncated. A second signal mid-shutdown reaches no handler (shutdown detaches them as it starts) and terminates on the OS default, 143 / 130 — the operator's force-kill escape hatch. `uncaughtException` / `unhandledRejection` exit 1.
172
171
 
173
172
  ---
174
173
 
@@ -238,7 +237,7 @@ export const myTool = tool('my_tool', {
238
237
  });
239
238
  ```
240
239
 
241
- **Steps:** Create `src/mcp-server/tools/definitions/[name].tool.ts` (kebab-case) → use `tool('snake_case', {...})` with Zod `.describe()` on all fields → implement `handler(input, ctx)` (pure, throws on failure) → add `auth`/`format` if needed → register in `definitions/index.ts` → `bun run devcheck` → smoke-test with `bun run rebuild && bun run start:stdio` (or `start:http`).
240
+ **Steps:** Create `src/mcp-server/tools/definitions/[name].tool.ts` (kebab-case) → use `tool('snake_case', {...})` with Zod `.describe()` on all fields → implement `handler(input, ctx)` (pure, throws on failure) → add `auth`/`format` if needed → register in `definitions/index.ts` → `bun run devcheck` → smoke-test with `bun run rebuild && bun run start:stdio < /dev/null` (or `start:http`) and confirm the `Core services constructed` log record lists the tool in its `tools` field (the message text shows only counts).
242
241
 
243
242
  **Schema constraint:** Input/output schemas must use JSON-Schema-serializable Zod types only. The MCP SDK converts schemas to JSON Schema for `tools/list` — non-serializable types (`z.custom()`, `z.date()`, `z.transform()`, `z.bigint()`, `z.symbol()`, `z.void()`, `z.map()`, `z.set()`, `z.function()`, `z.nan()`) cause a hard runtime failure. Use structural equivalents instead (e.g., `z.string()` with `.describe('ISO 8601 date')` instead of `z.date()`). The `schema-serializable` lint rule catches this at build time (`bun run lint:mcp` / `devcheck`).
244
243
 
@@ -255,7 +254,7 @@ export const myTool = tool('my_tool', {
255
254
 
256
255
  **Strict input:** `tool()` stores `input.strict()`, so an unrecognized argument key is rejected by name before the handler runs and `inputSchema` advertises `additionalProperties: false`. Root-level only — a nested `z.object()` still strips unless it is strict itself. An explicit `.passthrough()` / `.catchall()` is honored. Declare `.strict()` **before** `.describe()` / `.meta()` on the root: Zod keys both to the schema instance and `.strict()` clones without it, so a root describe declared after is discarded and never advertised — `lint:mcp` reports that as `schema-root-meta-discarded`.
257
256
 
258
- **Pre-validation:** an ordered step inside `parseToolArguments` rescues calls strict input would otherwise reject — drop client-added root keys (`_meta`, `tool_call_description`, `toolCallId`, any undeclared `_`-prefixed key) → rewrite key aliases (declared `inputAliases`, plus any undeclared key whose case-folded form names exactly one declared key) → parse → on failure, repair a JSON-stringified array and re-parse once, keeping it only if the schema then accepts it. All on by default; nothing changes the advertised `inputSchema`, and a call that still fails throws the identical rejection. A declared key, an author-opened root, and a `headerParam` target are never touched. Server-level switches: `createApp({ input: { ignoreKeys, caseStyleAliases, coerce } })`. Counters: `mcp.input.ignored_key`, `mcp.input.aliased`, `mcp.input.coerced`. Lint: `input-alias-conflict`. See `add-tool` skill.
257
+ **Pre-validation:** an ordered step inside `parseToolArguments` rescues calls strict input would otherwise reject — drop client-added root keys (`_meta`, `tool_call_description`, `toolCallId`, any undeclared `_`-prefixed key) → rewrite key aliases (declared `inputAliases`, plus any undeclared key whose case-folded form names exactly one declared key) → parse → on failure, repair a JSON-stringified array or object, or a safe integer where a string is expected (`8654467` → `"8654467"`), and re-parse once, keeping the repair only if the schema then accepts it. If that still fails and the drop discarded a key, the stages rerun alias-first so `_query` or a declared `_q` alias reaches its target, and the retry (repair included) is kept only if it validates — a call the first order validates resolves exactly as it would without the retry. All on by default, and nothing changes the advertised `inputSchema`. A call that still fails throws the rejection of the last order tried — the retry's when it ran, so a declared `_q` alias with a bad value reports that value's failure and `Validated _q as query.` — the same one it gets under `coerce: false`, reporting that order's rewrites and underscore-rule drops as `data.input` and in the hint. A declared key, an author-opened root, and a `headerParam` target are never touched. Server-level switches: `createApp({ input: { ignoreKeys, caseStyleAliases, coerce } })`. Counters, for the attempt the handler receives: `mcp.input.ignored_key`, `mcp.input.aliased`, `mcp.input.coerced` (once per repair kind). Lint: `input-alias-conflict`. See `add-tool` skill.
259
258
 
260
259
  **Header-mirrored input (2026-07-28):** `headerParam(z.string(), 'Region')` designates an input property with `x-mcp-header`, so its value also rides an `Mcp-Param-Region` request header and an intermediary can read it without parsing the body. Mirroring, not relocation — the handler still reads the argument from the body, and nothing else about the field changes. Only a primitive-typed (`string`/`integer`/`number`/`boolean`) property statically reachable through a chain of `properties` keys qualifies: an array element, a `z.record()` value, and every field of a discriminated-union input root are unreachable, and header names must be RFC 9110 tokens, case-insensitively unique per schema. `tool()` rejects a violation at definition time naming the field path — the SDK only warns, then conforming Streamable HTTP clients drop the tool. Lint rule: `header-param-designation`.
261
260
 
@@ -301,10 +300,11 @@ interface Context {
301
300
  readonly traceId?: string;
302
301
  readonly spanId?: string;
303
302
  readonly auth?: AuthContext;
303
+ readonly clientCapabilities: ClientCapabilities | undefined; // what the client declared; undefined when no view (2025-era stateless HTTP)
304
304
  readonly log: ContextLogger; // auto-correlated: requestId, traceId, tenantId
305
305
  readonly state: ContextState; // tenant-scoped KV storage
306
- readonly requestInput: RequestInputFn; // (spec) => never — suspends and asks the caller for input
307
- readonly inputs: ContextInputs; // reader over a retried request's responses
306
+ readonly requestInput: RequestInputFn; // (spec, options?) => never — suspends and asks the caller for input
307
+ readonly inputs: ContextInputs; // the request's responses, limited to what the client declared
308
308
  readonly notifyPromptListChanged?: (() => void) | undefined; // prompt list changed
309
309
  readonly notifyResourceListChanged?: (() => void) | undefined; // resource list changed
310
310
  readonly notifyResourceUpdated?: ((uri: string) => void) | undefined; // resource content changed
@@ -313,7 +313,7 @@ interface Context {
313
313
  readonly uri?: URL; // present for resource handlers
314
314
  readonly content: ContentCollect; // media blocks → prepended to content[]; never in structuredContent
315
315
  readonly enrich: Enrich; // success-path agent context → structuredContent + content[]; typed on HandlerContext<R, E>
316
- recoveryFor(reason: string): { recovery: { hint: string } } | Record<string, never>; // opt-in contract resolver
316
+ recoveryFor(reason: string): { recovery: { hint: string } } | Record<string, never>; // a declared entry's hint, in wire shape
317
317
  }
318
318
  ```
319
319
 
@@ -368,6 +368,33 @@ useFormat(answer.format);
368
368
  cancelled prompt is terminal, not a round to retry. `inputRequired.elicitUrl({ message, url })`
369
369
  hands the user an external link instead of a form.
370
370
 
371
+ A client can send responses on a call nothing asked for, so only what it declared reaches
372
+ `ctx.inputs`, at the mode level the request would need: an elicit result needs `elicitation` —
373
+ `elicitation.form` when it carries `content` — a sampling result `sampling` (`sampling.tools` when it
374
+ holds a tool block), a roots result `roots`, and a request with no capability view gets none.
375
+ `ctx.clientCapabilities` — the SDK-parsed `initialize` value on 2025-era connections (a bare
376
+ `elicitation: {}` reads back as `{ elicitation: { form: {} } }`), the request's envelope as sent on
377
+ 2026-07-28 — decides whether to *ask* for optional context (request roots only when `roots` is
378
+ declared, else fall through); it is never a reason to skip a consent prompt.
379
+
380
+ A 2025-era client that declared no matching capability is refused as `client_capability_missing`,
381
+ with a hint that ends at reconnecting. When the tool's own arguments can stand in for the answer,
382
+ say so per call — `ctx.requestInput(spec, { fallbackHint: 'Or call again with noun supplied.' })` —
383
+ and the sentence is appended to that hint. A consent gate passes none: it has no such field.
384
+
385
+ **Consent gates redeem a server record.** A capable client can still pre-answer, and any
386
+ `requestState` can be replayed within its lifetime, so a destructive handler first redeems (reads and
387
+ deletes) a `ctx.state` record `{ operation, clientId, subject, target, contentHash }` stored under a
388
+ random id — the id is all `requestState` carries — and asks again on an unknown, used, or expired id,
389
+ or when any field differs from this call. Carrying the target in `requestState` and comparing on
390
+ re-entry is replayable. Redeeming stops a sequential replay, not concurrent retries: until an atomic
391
+ `ctx.state.take` exists (#593), an action that must not repeat is made idempotent per record. The
392
+ record's storage is shared by every instance a 2026-07-28 retry can reach — `filesystem`, `supabase`,
393
+ or `cloudflare-d1`, never `cloudflare-kv`. `MCP_REQUEST_STATE_KEY` (≥ 32 bytes, the same on every
394
+ instance) opts into sealing: the framework signs the string a handler returns, the SDK rejects any
395
+ other as `-32602` `invalid_request_state` before the handler runs, and `ctx.inputs.state()` still
396
+ returns the original string. See `api-context` for the full pattern.
397
+
371
398
  ### `ctx.content`
372
399
 
373
400
  Accumulates non-text content blocks — image/audio bytes, embedded resources, resource links — onto the response: `ctx.content.image(data, mimeType)`, `ctx.content.audio(data, mimeType)`, or `ctx.content(block)` for a raw `ContentBlock`. Blocks are prepended to `content[]` after `format()` runs and never enter `structuredContent`, so a handler can emit media for the calling model without the base64 duplicating into typed output. Always present (no-op when unused); callable from handler and service layer.
@@ -378,7 +405,7 @@ See `api-context` skill for full details.
378
405
 
379
406
  ## Error Handling
380
407
 
381
- **Recommended path: declare a typed error contract.** Add `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` to `tool()` / `resource()`. Handler gets `ctx.fail(reason, msg?, data?)` typed against the reason union — typos fail at compile time. Runtime auto-populates `data.reason` for observability; linter enforces conformance against the handler body. `recovery` is required (≥5 words, lint-validated) — the single source of truth for the wire hint. Spread `ctx.recoveryFor('reason')` into `data` to opt the contract recovery onto the wire (framework mirrors `data.recovery.hint` into `content[]` text); override with explicit `{ recovery: { hint: '...' } }` when runtime context matters. Optional `severity` (`debug`/`info`/`notice`/`warning`) logs a modeled outcome below `error` — tools only, logging only: the wire envelope, the span status, and the `mcp.tool.*` metrics are untouched. Optional `thrownBy: 'service'` marks an entry the service layer produces, so `error-contract-unthrown` skips it while still checking the handler's own reasons — lint-only metadata, nothing at runtime reads it.
408
+ **Recommended path: declare a typed error contract.** Add `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` to `tool()` / `resource()`. Handler gets `ctx.fail(reason, msg?, data?)` typed against the reason union — typos fail at compile time. Runtime auto-populates `data.reason` for observability; linter enforces conformance against the handler body. `recovery` is required (≥5 words, lint-validated) — the wire hint for its reason: when a failure whose `data.reason` names the entry reaches the tool or resource factory without `data.recovery`, the framework fills `data.recovery.hint` from it — a bare `ctx.fail('reason')` and a service throw carrying `{ reason }` alike, matched on the reason alone — and mirrors it into `content[]` text. Override with explicit `{ recovery: { hint: '...' } }` when runtime context matters; a throw-site `recovery` always wins, and the thrown `McpError` itself is never changed. Optional `severity` (`debug`/`info`/`notice`/`warning`) logs a modeled outcome below `error` — tools only, logging only: the wire envelope, the span status, and the `mcp.tool.*` metrics are untouched. The framework's own `invalid_arguments` and `client_capability_missing` refusals log at `notice` unless an entry naming them declares otherwise. Optional `thrownBy: 'service'` marks an entry the service layer produces, so `error-contract-unthrown` skips it while still checking the handler's own reasons — lint-only metadata, nothing at runtime reads it.
382
409
 
383
410
  ```ts
384
411
  errors: [
@@ -390,8 +417,8 @@ errors: [
390
417
  recovery: 'Wait 30 seconds before retrying or reduce batch size.' },
391
418
  ],
392
419
  async handler(input, ctx) {
393
- // Static recovery — pulled from the contract via ctx.recoveryFor.
394
- if (queue.full()) throw ctx.fail('queue_full', undefined, { ...ctx.recoveryFor('queue_full') });
420
+ // Static recovery — the framework fills the contract's hint onto the wire.
421
+ if (queue.full()) throw ctx.fail('queue_full');
395
422
  // Dynamic recovery — interpolate runtime context, override the contract default.
396
423
  if (!matched) throw ctx.fail('no_match', `No data for ${input.pmids.length} PMIDs`, {
397
424
  pmids: input.pmids,
@@ -400,7 +427,7 @@ async handler(input, ctx) {
400
427
  }
401
428
  ```
402
429
 
403
- **`ctx.recoveryFor(reason)`** returns `{}` when no contract exists (spread-safe). Typed against the declared reason union on `HandlerContext<R>`. Works in services: `throw validationError(msg, { reason: 'X', ...ctx.recoveryFor('X') })`. Opt-in — author spreads explicitly.
430
+ **`ctx.recoveryFor(reason)`** returns the entry's hint in wire shape, `{}` when no contract exists (spread-safe). Typed against the declared reason union on `HandlerContext<R>`. Not needed to put a declared hint on the wire — the fill does that — only when the hint must ride the thrown error itself, as in a test of the handler's own throw.
404
431
 
405
432
  **Contracts are inline, per-tool.** Don't extract shared `errors[]` constants — locality is the point, and dynamic `recovery` hints need tool-specific context. Declare domain-specific failures only; **baseline codes** (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) are auto-allowed by conformance lint. The lint scans handler source only — service-layer throws still reach clients via auto-classification.
406
433
 
@@ -418,9 +445,9 @@ For HTTP responses from upstream APIs, use `httpErrorFromResponse(response, { se
418
445
 
419
446
  **Auto-classification.** Plain `Error`, `ZodError`, and any other thrown value are caught and classified automatically. Resolution order: request signal already aborted (→ `RequestCancelled`, outranking the thrown value's own code, `McpError` included) → `McpError` code (preserved as-is) → SDK `ConnectionClosed` (→ `RequestCancelled`) → engine resource-limit `RangeError` by whole message — stack overflow, maximum string size (→ `InternalError`) → JS constructor name (`SyntaxError` → `ValidationError`; `TypeError` is excluded) → provider patterns (HTTP status codes, AWS errors, DB errors) → common message patterns → `AbortError` name (→ `Timeout`) → `InternalError` fallback. A result that breaks the definition's own `output` or `enrichment` schema fails as `InternalError` naming that contract, not `ValidationError`.
420
447
 
421
- **Error-path parity.** Tool errors: `content[]` carries `Error: <message>`, then `Recovery: <hint>` when the hint says something the message does not already contain, then a closing `(reason … · not retryable)` for whichever of `data.reason` / `data.retryable` is present; the numeric code and `data.issues` stay JSON-only. `structuredContent.error` carries `{ code, message, data? }`. No `_meta.error`. Resources re-throw via JSON-RPC error envelope. An argument rejection is one of them: `-32602` with `data.issues`, plus `data.reason: 'invalid_arguments'` and a hint synthesized from the issues and the root schema — never a tool-declared `reason`, since the handler never ran. `client_capability_missing` is the second framework-owned reason: a `ctx.requestInput` return a 2025-era connection cannot serve is refused before any wire traffic as `-32600` carrying that reason and a hint naming the capability.
448
+ **Error-path parity.** Tool errors: `content[]` carries `Error: <message>`, then `Recovery: <hint>` when the hint says something the message does not already contain, then a closing `(reason … · not retryable · request <id>)` for whichever of `data.reason` / `data.retryable` / `data.requestId` is present; the numeric code and `data.issues` stay JSON-only. `structuredContent.error` carries `{ code, message, data? }`. No `_meta.error`. Resources re-throw via JSON-RPC error envelope. An argument rejection is one of them: `-32602` with `data.issues`, plus `data.reason: 'invalid_arguments'` and a hint synthesized from the issues and the root schema — never a tool-declared `reason`, since the handler never ran. When pre-validation rewrote or dropped a key the caller wrote, `data.input` (`{ aliased: [{ alias, target }], ignored }`) names it and the hint closes with `Validated … as ….` / `Dropped undeclared key ….`; an ignore-list drop is never reported. `client_capability_missing` is the second framework-owned reason: a `ctx.requestInput` return a 2025-era connection cannot serve is refused before any wire traffic as `-32600` carrying that reason and a hint naming the capability. **`data.requestId`** is set on every error envelope the framework builds — tool results, resource reads, prompts, `httpErrorHandler`'s JSON-RPC errors — equal to the `requestId` of that call's log records: a generated token, or the client's JSON-RPC id when it is a string (`httpErrorHandler` always generates its own). A resource read refused before it is measured (auth, `params`) carries an id no record shares. It replaces a thrown `data.requestId`, is never added to the thrown `McpError`, and is left off a resource `-32602` whose `data` is exactly `{ uri }` and off `runToolContract` results.
422
449
 
423
- **Lint rules** (all warnings, surfaced in `devcheck`): `prefer-mcp-error-in-handler`, `prefer-error-factory`, `preserve-cause-on-rethrow`, `no-stringify-upstream-error`, `error-contract-conformance`, `error-contract-prefer-fail`, `error-contract-unthrown` (a declared reason no literal `ctx.fail`/`ctx.recoveryFor` in the handler names, unless marked `thrownBy: 'service'`), `error-contract-recovery-unforwarded` (a `ctx.fail` site carrying neither `ctx.recoveryFor('<reason>')` nor its own `recovery` key, so the declared hint reaches neither client surface). See `api-linter` skill.
450
+ **Lint rules** (all warnings, surfaced in `devcheck`): `prefer-mcp-error-in-handler`, `prefer-error-factory`, `preserve-cause-on-rethrow`, `no-stringify-upstream-error`, `error-contract-conformance`, `error-contract-prefer-fail`, `error-contract-unthrown` (a declared reason no literal `ctx.fail`/`ctx.recoveryFor` in the handler names, unless marked `thrownBy: 'service'`). See `api-linter` skill.
424
451
 
425
452
  See `api-errors` skill for the full pattern-matching table, error code reference, and detailed examples.
426
453
 
@@ -454,7 +481,7 @@ Managed by `@cyanheads/mcp-ts-core`. Validated via Zod. Precedence: `createApp()
454
481
  | Category | Key Variables |
455
482
  |:---------|:-------------|
456
483
  | Transport | `MCP_TRANSPORT_TYPE` (`stdio`\|`http`), `MCP_HTTP_PORT`, `MCP_HTTP_HOST`, `MCP_HTTP_ENDPOINT_PATH` |
457
- | Auth | `MCP_AUTH_MODE`, `MCP_AUTH_SECRET_KEY`, `MCP_AUTH_DISABLE_SCOPE_CHECKS`, `OAUTH_*` |
484
+ | Auth | `MCP_AUTH_MODE`, `MCP_AUTH_SECRET_KEY`, `MCP_AUTH_DISABLE_SCOPE_CHECKS`, `OAUTH_*`, `MCP_REQUEST_STATE_KEY` (opt-in `requestState` sealing) |
458
485
  | Storage | `STORAGE_PROVIDER_TYPE` (`in-memory`\|`filesystem`\|`supabase`\|`cloudflare-r2`\|`cloudflare-kv`\|`cloudflare-d1`) |
459
486
  | LLM | `OPENROUTER_API_KEY`, `OPENROUTER_APP_URL/NAME`, `LLM_DEFAULT_*` |
460
487
  | Telemetry | `OTEL_ENABLED`, `OTEL_SERVICE_NAME/VERSION`, `OTEL_EXPORTER_OTLP_*` |
@@ -481,7 +508,7 @@ describe('myTool', () => {
481
508
  });
482
509
  ```
483
510
 
484
- **`createMockContext` options:** `createMockContext()` (state included), `{ tenantId: 'test-tenant' }` (explicit tenant; defaults to `'default'`, as stdio resolves it), `{ errors: myTool.errors }` (typed `ctx.fail`), `{ inputResponses }` / `{ requestState }` (seed `ctx.inputs` to drive a multi-round-trip handler's second round directly), plus `auth`, `sessionId`, `signal`, `requestId`, `uri`, and the four `notify*` callbacks.
511
+ **`createMockContext` options:** `createMockContext()` (state included), `{ tenantId: 'test-tenant' }` (explicit tenant; defaults to `'default'`, as stdio resolves it), `{ errors: myTool.errors }` (typed `ctx.fail`), `{ inputResponses }` / `{ requestState }` (seed `ctx.inputs` to drive a multi-round-trip handler's second round directly), `{ clientCapabilities }` (seeds `ctx.clientCapabilities` and filters the seeded responses to the declared kinds, as production does — omitted, nothing is filtered), plus `auth`, `sessionId`, `signal`, `requestId`, `uri`, and the four `notify*` callbacks.
485
512
 
486
513
  **`ctx.state` in tests is the production path.** The mock backs it with a real `StorageService` over an `InMemoryProvider`, so key validation (`[a-zA-Z0-9_.\-/]+` — colons rejected), the JSON round-trip of every value, and TTL expiry behave exactly as they do in a deployment. Passing `errors` narrows the return type to `HandlerContext<ReasonOf<…>>`, which is what a definition declaring a contract types its handler's `ctx` as — so `definition.handler(input, ctx)` typechecks.
487
514
 
@@ -491,7 +518,7 @@ describe('myTool', () => {
491
518
 
492
519
  **Fixture-based tests:** `mcpTest` from `/testing/vitest` (optional peer `vitest`) extends Vitest's `test` with per-test fixtures: `ctx` (fresh mock context), `session` (session-bound context), `fetchMock` (strict fetch fake, installed/restored around the test), and `storage` (fresh in-memory `StorageService`). Override fixtures via `.extend` with the function form only — a bare value would share one mutable context across every test in the file.
493
520
 
494
- **Tool contracts:** `runToolContract(definition, input)` from `/testing` validates input/output, invokes the handler, formats content, and returns production-shaped success/error surfaces without transport auth or telemetry. `toolContractSuite(definition, { success, errors })` from `/testing/vitest` registers reusable schema/handler/error-envelope conformance cases for a server's own tool definitions.
521
+ **Tool contracts:** `runToolContract(definition, input)` from `/testing` validates input/output, invokes the handler, formats content, and returns production-shaped success/error surfaces — the declared `recovery` fill included — without transport auth, telemetry, or `data.requestId`. `toolContractSuite(definition, { success, errors })` from `/testing/vitest` registers reusable schema/handler/error-envelope conformance cases for a server's own tool definitions.
495
522
 
496
523
  **Fuzz testing:** `fuzzTool`/`fuzzResource`/`fuzzPrompt` from `/testing/fuzz` generate valid and adversarial inputs from Zod schemas via `fast-check`, then assert handler invariants (no crashes, no prototype pollution, no stack trace leaks). Returns a `FuzzReport` for custom assertions.
497
524
 
@@ -540,10 +567,10 @@ Skills live in `framework-skills/<name>/SKILL.md`; the full list is discoverable
540
567
  - **Auth:** via `auth: ['scope']` on definitions (not HOF wrapper)
541
568
  - **Missing input:** read `ctx.inputs` first, then `return ctx.requestInput(...)`
542
569
  - **Pagination:** large resource lists use `extractCursor`/`paginateArray`
543
- - **Registration:** definitions exported in `definitions/index.ts` barrel
570
+ - **Registration:** definitions collected in the `definitions/index.ts` barrel's array passed to `createApp()` — an `export` line alone registers nothing
544
571
  - **Tests:** `createMockContext()`, `.handler()` tested directly
545
572
  - **Gate:** `bun run devcheck` passes (includes MCP definition linting)
546
- - **Smoke-test:** `bun run rebuild && bun run start:stdio` (or `start:http`)
573
+ - **Smoke-test:** `bun run rebuild && bun run start:stdio < /dev/null` (or `start:http`); the `Core services constructed` log record lists every definition in its `tools` / `resources` / `prompts` fields
547
574
 
548
575
  ---
549
576
 
@@ -566,7 +593,7 @@ Skills live in `framework-skills/<name>/SKILL.md`; the full list is discoverable
566
593
  | `bun run test:leak-gate` | The retention gate's own sentinel suite. Each case spawns a full Vitest run, so it is excluded from the `unit` project |
567
594
  | `bun run test:coverage` | Root projects with coverage thresholds enforced |
568
595
  | `bun run test:integration` | Real server subprocesses over stdio and HTTP |
569
- | `bun run test:worker` | The framework under real `workerd`, then a standalone Worker bundle through the Wrangler toolchain — real Node on both legs |
596
+ | `bun run test:worker` | The framework under real `workerd`, then a standalone Worker bundle through the Wrangler toolchain — real Node on both legs. The `workerd` leg enforces its own coverage thresholds over the Worker entry and Cloudflare storage providers, reported to `reports/coverage-worker/` |
570
597
  | `bun run test:package` | Rebuilds, packs the tarball, and consumes it as an external project would (exports, declarations, both runtimes) |
571
598
  | `bun run test:node` | Root projects + integration under real Node via `scripts/with-node.ts`, which bypasses Bun's `node` PATH shim |
572
599
  | `bun run test:order` | Root projects on real Node in shuffled file order under a pinned seed — catches inter-file state leakage |
package/README.md CHANGED
@@ -6,9 +6,9 @@
6
6
 
7
7
  <div align="center">
8
8
 
9
- [![Version](https://img.shields.io/badge/Version-0.13.8-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![MCP Spec](https://img.shields.io/badge/MCP%20Spec-2026--07--28-8A2BE2.svg?style=flat-square)](https://modelcontextprotocol.io/specification/2026-07-28)
9
+ [![Version](https://img.shields.io/badge/Version-0.13.10-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![MCP Spec](https://img.shields.io/badge/MCP%20Spec-2026--07--28-8A2BE2.svg?style=flat-square)](https://modelcontextprotocol.io/specification/2026-07-28)
10
10
 
11
- [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.0.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.0%2B-blueviolet.svg?style=flat-square)](https://bun.sh/)
11
+ [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.1.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.0%2B-blueviolet.svg?style=flat-square)](https://bun.sh/)
12
12
 
13
13
  [Quick start](#quick-start) · [Capabilities](#what-comes-with-it) · [API reference](#api-overview) · [Examples](#examples)
14
14
 
@@ -127,9 +127,7 @@ const search = tool('search', {
127
127
  ],
128
128
  handler: async (input, ctx) => {
129
129
  const res = await runSearch(input.query, input.limit);
130
- if (!res) {
131
- throw ctx.fail('index_unavailable', undefined, ctx.recoveryFor('index_unavailable'));
132
- }
130
+ if (!res) throw ctx.fail('index_unavailable');
133
131
  ctx.enrich({ effectiveQuery: res.parsed, totalCount: res.total });
134
132
  if (res.items.length === 0) {
135
133
  ctx.enrich({ notice: `No matches for "${input.query}". Try broader terms.` });
@@ -141,7 +139,7 @@ const search = tool('search', {
141
139
  await createApp({ tools: [search] });
142
140
  ```
143
141
 
144
- Both contracts are advertised in `tools/list`, so clients see them before calling, and the definition linter checks the handler against them. `ctx.recoveryFor()` adds the declared recovery hint to the error response.
142
+ Both contracts are advertised in `tools/list`, so clients see them before calling, and the definition linter checks the handler against them. A failure with a declared reason reaches the client carrying that entry's recovery hint, and every tool error carries the request ID its server log records share.
145
143
 
146
144
  ### Same data across client surfaces
147
145
 
@@ -236,6 +234,7 @@ Core config comes from environment variables, validated with Zod. Server-specifi
236
234
  | `MCP_HTTP_HOST` | HTTP server hostname | `127.0.0.1` |
237
235
  | `MCP_AUTH_MODE` | `none`, `jwt`, or `oauth` | `none` |
238
236
  | `MCP_AUTH_SECRET_KEY` | JWT signing secret (required for `jwt` mode) | — |
237
+ | `MCP_REQUEST_STATE_KEY` | Opt-in key (≥ 32 bytes, the same on every instance) that seals the `requestState` handlers return and rejects any a client did not get from this server | — |
239
238
  | `STORAGE_PROVIDER_TYPE` | `in-memory`, `filesystem`, `supabase`, `cloudflare-d1`/`kv`/`r2` | `in-memory` |
240
239
  | `CANVAS_PROVIDER_TYPE` | `none` or `duckdb` (optional peer dependency `@duckdb/node-api`) | `none` |
241
240
  | `OTEL_ENABLED` | Enable OpenTelemetry | `false` |
@@ -273,17 +272,18 @@ Tool and resource handlers receive a `Context`. `ctx.enrich` and `ctx.fail` are
273
272
  | `ctx.log` | `ContextLogger` | Request-scoped logger (auto-correlates requestId, traceId, tenantId); also mirrored to the client as `notifications/message` |
274
273
  | `ctx.state` | `ContextState` | Tenant-scoped key-value storage |
275
274
  | `ctx.requestInput` | `(spec) => never` | Suspend and ask the caller for more input; the handler is re-entered with the answers |
276
- | `ctx.inputs` | `ContextInputs` | Reader over a retried request's responses — `.accepted()`, `.view()`, `.state()`, `.dropped` |
275
+ | `ctx.inputs` | `ContextInputs` | The request's responses, limited to the kinds the client declared — `.accepted()`, `.view()`, `.state()`, `.dropped` |
276
+ | `ctx.clientCapabilities` | `ClientCapabilities \| undefined` | What the client declared for this request; decides whether to ask for optional context, never whether to skip a consent prompt |
277
277
  | `ctx.enrich` | `Enrich` / `TypedEnrich<E>` | Add declared result context to structured output and text content |
278
278
  | `ctx.content` | `ContentCollect` | Attach image/audio blocks to `content[]` — `content.image(data, mimeType)`, `content.audio(...)`, or a raw block |
279
279
  | `ctx.fail` | `(reason, msg?, data?) => McpError` | Creates an error for `throw ctx.fail(...)`; available with a declared `errors` contract |
280
- | `ctx.recoveryFor` | `(reason) => object` | Resolves a declared recovery hint to `{ recovery: { hint } }`, for `ctx.fail`'s data argument |
280
+ | `ctx.recoveryFor` | `(reason) => object` | Resolves a declared recovery hint to `{ recovery: { hint } }`; the framework already sends it with any failure carrying that reason and no hint of its own |
281
281
  | `ctx.signal` | `AbortSignal` | Cancellation signal |
282
282
  | `ctx.notifyResourceUpdated` | `Function?` | Notify subscribed clients a resource changed |
283
283
  | `ctx.notifyResourceListChanged` | `Function?` | Notify clients the resource list changed |
284
284
  | `ctx.notifyPromptListChanged` | `Function?` | Notify clients the prompt list changed |
285
285
  | `ctx.notifyToolListChanged` | `Function?` | Notify clients the tool list changed |
286
- | `ctx.requestId` | `string` | Unique request ID |
286
+ | `ctx.requestId` | `string` | Request ID — shared by the call's log records and returned on its errors as `data.requestId` |
287
287
  | `ctx.tenantId` | `string?` | Tenant ID (JWT `tid` claim, or `'default'` for stdio and HTTP+`MCP_AUTH_MODE=none`) |
288
288
  | `ctx.auth` | `AuthContext?` | Token claims and scopes when the request is authenticated |
289
289
  | `ctx.sessionId` | `string?` | HTTP session ID in stateful/`auto` session mode — a scoping key, not an authorization principal |
@@ -334,7 +334,7 @@ const input = myTool.input.parse({ query: 'test' });
334
334
  const result = await myTool.handler(input, ctx);
335
335
  ```
336
336
 
337
- `createMockContext()` gives you a recording `log`, a `signal`, and a `state` backed by a real `StorageService` over an in-memory provider, so key validation, TTL expiry, and the JSON round-trip of stored values behave as they do in production: a `Date` reads back as its ISO string, and a value JSON cannot encode rejects. It uses tenant `'default'` unless you pass `{ tenantId }`. Pass `{ errors: myTool.errors }` for a typed `ctx.fail`, or `{ inputResponses, requestState }` to start a multi-round-trip handler at its second round.
337
+ `createMockContext()` gives you a recording `log`, a `signal`, and a `state` backed by a real `StorageService` over an in-memory provider, so key validation, TTL expiry, and the JSON round-trip of stored values behave as they do in production: a `Date` reads back as its ISO string, and a value JSON cannot encode rejects. It uses tenant `'default'` unless you pass `{ tenantId }`. Pass `{ errors: myTool.errors }` for a typed `ctx.fail`, `{ inputResponses, requestState }` to start a multi-round-trip handler at its second round, or `{ clientCapabilities }` to set what the client declared (seeded responses are then filtered to the declared kinds, as in production).
338
338
 
339
339
  `/testing` also exports `createMockSession()` for session-bound contexts, `createFetchMock()` as a strict fake for upstream HTTP, and `runToolContract()`, which runs a definition through schema, handler, formatting, and error-envelope checks. `/testing/vitest` adds the `mcpTest` fixtures (`ctx`, `session`, `fetchMock`, `storage`) and `toolContractSuite()`.
340
340