@cyanheads/mcp-ts-core 0.13.9 → 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 (119) hide show
  1. package/AGENTS.md +36 -13
  2. package/CLAUDE.md +36 -13
  3. package/README.md +9 -9
  4. package/changelog/0.13.x/0.13.10.md +118 -0
  5. package/dist/config/index.d.ts +3 -0
  6. package/dist/config/index.d.ts.map +1 -1
  7. package/dist/config/index.js +11 -0
  8. package/dist/config/index.js.map +1 -1
  9. package/dist/core/app.d.ts.map +1 -1
  10. package/dist/core/app.js +15 -1
  11. package/dist/core/app.js.map +1 -1
  12. package/dist/core/context.d.ts +89 -20
  13. package/dist/core/context.d.ts.map +1 -1
  14. package/dist/core/context.js +40 -0
  15. package/dist/core/context.js.map +1 -1
  16. package/dist/core/index.d.ts +1 -1
  17. package/dist/core/index.d.ts.map +1 -1
  18. package/dist/core/index.js.map +1 -1
  19. package/dist/core/worker.d.ts +6 -0
  20. package/dist/core/worker.d.ts.map +1 -1
  21. package/dist/core/worker.js +1 -0
  22. package/dist/core/worker.js.map +1 -1
  23. package/dist/linter/rules/error-contract-rules.d.ts +3 -44
  24. package/dist/linter/rules/error-contract-rules.d.ts.map +1 -1
  25. package/dist/linter/rules/error-contract-rules.js +8 -144
  26. package/dist/linter/rules/error-contract-rules.js.map +1 -1
  27. package/dist/linter/rules/index.d.ts +1 -1
  28. package/dist/linter/rules/index.d.ts.map +1 -1
  29. package/dist/linter/rules/index.js +1 -1
  30. package/dist/linter/rules/index.js.map +1 -1
  31. package/dist/linter/rules/resource-rules.d.ts.map +1 -1
  32. package/dist/linter/rules/resource-rules.js +1 -2
  33. package/dist/linter/rules/resource-rules.js.map +1 -1
  34. package/dist/linter/rules/tool-rules.d.ts.map +1 -1
  35. package/dist/linter/rules/tool-rules.js +1 -2
  36. package/dist/linter/rules/tool-rules.js.map +1 -1
  37. package/dist/mcp-server/handlerContext.d.ts +26 -13
  38. package/dist/mcp-server/handlerContext.d.ts.map +1 -1
  39. package/dist/mcp-server/handlerContext.js +32 -17
  40. package/dist/mcp-server/handlerContext.js.map +1 -1
  41. package/dist/mcp-server/inputRequired.d.ts +120 -8
  42. package/dist/mcp-server/inputRequired.d.ts.map +1 -1
  43. package/dist/mcp-server/inputRequired.js +177 -12
  44. package/dist/mcp-server/inputRequired.js.map +1 -1
  45. package/dist/mcp-server/prompts/prompt-registration.d.ts +10 -2
  46. package/dist/mcp-server/prompts/prompt-registration.d.ts.map +1 -1
  47. package/dist/mcp-server/prompts/prompt-registration.js +49 -11
  48. package/dist/mcp-server/prompts/prompt-registration.js.map +1 -1
  49. package/dist/mcp-server/resources/resource-registration.d.ts +4 -2
  50. package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
  51. package/dist/mcp-server/resources/resource-registration.js +6 -4
  52. package/dist/mcp-server/resources/resource-registration.js.map +1 -1
  53. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts +4 -3
  54. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
  55. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +42 -14
  56. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
  57. package/dist/mcp-server/server.d.ts +9 -0
  58. package/dist/mcp-server/server.d.ts.map +1 -1
  59. package/dist/mcp-server/server.js +14 -13
  60. package/dist/mcp-server/server.js.map +1 -1
  61. package/dist/mcp-server/tools/tool-registration.d.ts +7 -3
  62. package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
  63. package/dist/mcp-server/tools/tool-registration.js +9 -5
  64. package/dist/mcp-server/tools/tool-registration.js.map +1 -1
  65. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +17 -9
  66. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  67. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +91 -50
  68. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  69. package/dist/mcp-server/transports/http/httpErrorHandler.d.ts.map +1 -1
  70. package/dist/mcp-server/transports/http/httpErrorHandler.js +7 -1
  71. package/dist/mcp-server/transports/http/httpErrorHandler.js.map +1 -1
  72. package/dist/testing/index.d.ts +17 -2
  73. package/dist/testing/index.d.ts.map +1 -1
  74. package/dist/testing/index.js +21 -7
  75. package/dist/testing/index.js.map +1 -1
  76. package/dist/types-global/errors.d.ts +18 -15
  77. package/dist/types-global/errors.d.ts.map +1 -1
  78. package/dist/utils/internal/error-handler/errorHandler.d.ts +5 -4
  79. package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
  80. package/dist/utils/internal/error-handler/errorHandler.js +9 -7
  81. package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
  82. package/dist/utils/internal/error-handler/types.d.ts +3 -1
  83. package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
  84. package/dist/utils/internal/performance.d.ts +4 -2
  85. package/dist/utils/internal/performance.d.ts.map +1 -1
  86. package/dist/utils/internal/performance.js +8 -6
  87. package/dist/utils/internal/performance.js.map +1 -1
  88. package/dist/utils/internal/telemetryMessages.d.ts +0 -1
  89. package/dist/utils/internal/telemetryMessages.d.ts.map +1 -1
  90. package/dist/utils/internal/telemetryMessages.js +0 -1
  91. package/dist/utils/internal/telemetryMessages.js.map +1 -1
  92. package/dist/utils/telemetry/attributes.d.ts +5 -4
  93. package/dist/utils/telemetry/attributes.d.ts.map +1 -1
  94. package/dist/utils/telemetry/attributes.js +5 -4
  95. package/dist/utils/telemetry/attributes.js.map +1 -1
  96. package/framework-skills/add-service/SKILL.md +3 -12
  97. package/framework-skills/add-test/SKILL.md +6 -3
  98. package/framework-skills/add-tool/SKILL.md +29 -33
  99. package/framework-skills/api-config/SKILL.md +2 -1
  100. package/framework-skills/api-context/SKILL.md +155 -40
  101. package/framework-skills/api-errors/SKILL.md +43 -47
  102. package/framework-skills/api-linter/SKILL.md +5 -29
  103. package/framework-skills/api-telemetry/SKILL.md +11 -7
  104. package/framework-skills/api-testing/SKILL.md +43 -11
  105. package/framework-skills/api-workers/SKILL.md +3 -1
  106. package/framework-skills/design-mcp-server/SKILL.md +5 -5
  107. package/framework-skills/field-test/SKILL.md +2 -2
  108. package/framework-skills/polish-docs-meta/SKILL.md +2 -2
  109. package/framework-skills/release-and-publish/SKILL.md +3 -3
  110. package/framework-skills/release-pr-review/SKILL.md +2 -2
  111. package/framework-skills/security-pass/SKILL.md +8 -7
  112. package/package.json +4 -3
  113. package/scripts/install-otel.ts +84 -0
  114. package/scripts/lint-packaging.ts +188 -27
  115. package/templates/.env.example +2 -0
  116. package/templates/AGENTS.md +5 -4
  117. package/templates/CLAUDE.md +5 -4
  118. package/templates/Dockerfile +67 -50
  119. package/templates/src/mcp-server/tools/definitions/echo.tool.ts +3 -8
@@ -4,7 +4,7 @@ description: >
4
4
  MCP definition linter rules reference. Use when `bun run lint:mcp` or `bun run devcheck` reports a lint error or warning (`format-parity`, `schema-is-object`, `name-format`, `server-json-*`, etc.) and you need to understand the rule, its severity, and how to fix it. Every rule ID the linter emits has an entry in this doc.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.20"
7
+ version: "1.21"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -53,7 +53,7 @@ Grouped by family. Jump to any rule ID via its anchor.
53
53
  | Prompts | `generate-required` | [Prompt rules](#prompt-rules) |
54
54
  | Handler body | `prefer-mcp-error-in-handler`, `prefer-error-factory`, `preserve-cause-on-rethrow`, `no-stringify-upstream-error` | [Handler body rules](#handler-body-rules) |
55
55
  | Error contract (structural) | `error-contract-type`, `error-contract-empty`, `error-contract-entry-type`, `error-contract-code-type`, `error-contract-code-unknown`, `error-contract-code-unknown-error`, `error-contract-reason-required`, `error-contract-reason-format`, `error-contract-reason-unique`, `error-contract-when-required`, `error-contract-retryable-type`, `error-contract-severity-unknown`, `error-contract-recovery-required`, `error-contract-recovery-empty`, `error-contract-recovery-min-words` | [Error contract rules](#error-contract-rules) |
56
- | Error contract (conformance) | `error-contract-conformance`, `error-contract-prefer-fail`, `error-contract-unthrown`, `error-contract-recovery-unforwarded` | [Error contract rules](#error-contract-rules) |
56
+ | Error contract (conformance) | `error-contract-conformance`, `error-contract-prefer-fail`, `error-contract-unthrown` | [Error contract rules](#error-contract-rules) |
57
57
  | Enrichment | `enrichment-type`, `enrichment-empty`, `enrichment-field-type`, `enrichment-output-collision`, `enrichment-prefer-block`, `enrichment-trailer-render`, `enrichment-trailer-orphan`, `enrichment-trailer-unknown-field`, `capped-list-no-truncation` | [Enrichment rules](#enrichment-rules) |
58
58
  | server.json | ~40 rules prefixed `server-json-*` | [server.json rules](#server-json-rules) |
59
59
 
@@ -866,7 +866,7 @@ Fires when an entry's optional `severity` field is present but isn't one of `deb
866
866
 
867
867
  **Severity:** error
868
868
 
869
- Fires when an entry's `recovery` field is missing or not a string. `recovery` is the agent's next-move guidance when this failure fires — it flows to the wire via `ctx.recoveryFor`.
869
+ Fires when an entry's `recovery` field is missing or not a string. `recovery` is the agent's next-move guidance when this failure fires — the handler factory sends it as `data.recovery.hint` with any failure carrying the entry's reason and no hint of its own.
870
870
 
871
871
  ### error-contract-recovery-empty
872
872
 
@@ -948,37 +948,13 @@ async handler(input, ctx) {
948
948
  }
949
949
  ```
950
950
 
951
- The field is lint-only metadata: `ctx.fail`, `ctx.recoveryFor`, the `severity` lookup, and the advertised error envelope never read it, so a marked entry is typed, advertised, and thrown exactly as an unmarked one. Prefer it over the workarounds that also silence the rule — moving the literal `ctx.fail` into a module-level helper turns the whole tool off, handler-local reasons included.
951
+ The field is lint-only metadata: `ctx.fail`, `ctx.recoveryFor`, the recovery fill, the `severity` lookup, and the advertised error envelope never read it, so a marked entry is typed, advertised, and thrown exactly as an unmarked one. Prefer it over the workarounds that also silence the rule — moving the literal `ctx.fail` into a module-level helper turns the whole tool off, handler-local reasons included.
952
952
 
953
953
  **Trigger.** Only when the handler holds at least one literal `ctx.fail(`. A handler with none produces its reasons somewhere the scan cannot reach, so firing there would warn on every service-layer definition. A `ctx.fail(` or `ctx.recoveryFor(` whose first argument is not a string literal — a variable, a template literal, a map lookup — makes the named set unknowable, and the whole definition is skipped rather than guessed at.
954
954
 
955
955
  **Heuristic limitations:** the scan reads `handler.toString()` and matches call sites in the comment- and string-stripped text, so a `ctx.fail('…')` written inside a comment or nested in another literal does not count as thrown. A reason produced outside the handler closure is invisible to any `toString()` scan, which is why the rule can never prove absence and stays a warning. Still silent without a marker: a `createFail(errors)` resolver built outside the handler, and an aliased `const fail = ctx.fail`.
956
956
 
957
- ### error-contract-recovery-unforwarded
958
-
959
- **Severity:** warning
960
-
961
- Fires per literal `ctx.fail('<reason>', …)` site that does not put the contract's `recovery` on the wire.
962
-
963
- `recovery` is required on every `errors[]` entry, but reaching the client with it is opt-in — the throw site forwards `ctx.recoveryFor('<reason>')`, or passes its own `recovery` key. A site that does neither ships `reason` and `retryable` with no hint, and since the framework mirrors `data.recovery.hint` into the error `content[]`, both client surfaces lose it together. Nothing else catches this: the contract is declared, `lint:mcp` passes, and an error-path test asserting `code` and `reason` passes with the hint absent.
964
-
965
- **Fix:** forward the resolver at the site named in the diagnostic.
966
-
967
- ```ts
968
- // warns
969
- throw ctx.fail('rate_limited', 'Upstream rate limit exceeded');
970
-
971
- // clean — any of
972
- throw ctx.fail('rate_limited', msg, { ...ctx.recoveryFor('rate_limited') });
973
- throw ctx.fail('rate_limited', msg, ctx.recoveryFor('rate_limited'));
974
- throw ctx.fail('rate_limited', msg, { recovery: { hint: `Retry in ${waitSeconds}s.` } });
975
- ```
976
-
977
- **Per site, not per reason.** A handler wiring one of six throws is covered at one of them, so each site is judged on its own argument list. Two sites naming one reason, one forwarding and one bare, produce exactly one diagnostic. A site whose only resolver names a *different* reason warns too, naming both — the caller would otherwise get another failure mode's guidance.
978
-
979
- **Bails.** A non-literal first argument on either `ctx.fail(` or `ctx.recoveryFor(` skips the whole definition, as it does for `error-contract-unthrown`. A resolver sitting outside every fail span — a hoisted `const hint = ctx.recoveryFor('x')` — skips that reason, since the binding is assembled where the scan cannot follow it. A data argument the scan cannot read skips that one site: an identifier (`ctx.fail('r', msg, data)`), a call other than the resolver, or an object literal spreading another value (`{ ...details }`), any of which may carry `recovery` already. An object literal of plain keys carrying no `recovery` still warns.
980
-
981
- **Heuristic limitations:** same `handler.toString()` scan as `error-contract-unthrown`, so a call written inside a comment or nested in another literal is not a site, and a failure thrown below the handler is invisible. The rule speaks only for the sites it sees, which is why it stays a warning.
957
+ No rule checks that a throw site forwards the declared `recovery`: the handler factory fills `data.recovery.hint` from the entry for any failure carrying its reason and no hint of its own, so a bare `ctx.fail('<reason>')` and a service throw both reach the client with it. See `api-errors`.
982
958
 
983
959
  ---
984
960
 
@@ -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.15"
7
+ version: "1.16"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -214,15 +214,17 @@ A dashboard reading `error_category` alone therefore no longer needs to special-
214
214
  A definition may put `severity` on an `errors[]` entry — `debug`, `info`, `notice`, or `warning` — for an outcome it models rather than suffers. Two things move, and nothing else:
215
215
 
216
216
  - The `Error in tool:<name>` log record is emitted at that level instead of `error`, with the same message and structured fields.
217
- - `mcp.errors.classified` gains `mcp.error.severity` on that record. It is set only when a declared severity resolved, so a server that declares none emits exactly the series it did before.
217
+ - `mcp.errors.classified` gains `mcp.error.severity` on that record. It is set only when a severity resolved below `error`.
218
218
 
219
- The call still failed: the execution span keeps `SpanStatusCode.ERROR` and its recorded exception, `mcp.tool.calls` / `mcp.tool.duration` / `mcp.tool.errors` record the same values, and the completion log still reads `isSuccess: false`. Splitting those series on an authoring decision would redefine what an error rate means. Tools only — resources re-throw for the SDK to log. A cancelled request keeps its own `info`, stack-free path whatever the contract declares. See `api-errors`.
219
+ The framework's own refusals resolve one without a declaration: an argument rejection (`invalid_arguments`) and a `ctx.requestInput` the client connection cannot serve (`client_capability_missing`) log at `notice`, so even a server that declares no severity sees `mcp.error.severity: "notice"` on those `mcp.errors.classified` increments — a bounded split a dashboard can use to separate caller rejections from faults. An argument rejection opens no execution span and reaches no call counter either way; it still counts once on `mcp.tool.rejections`.
220
+
221
+ The call still failed: the execution span keeps `SpanStatusCode.ERROR` and its recorded exception, `mcp.tool.calls` / `mcp.tool.duration` / `mcp.tool.errors` record the same values, and the completion log still reads `isSuccess: false`. Splitting those series on an authoring decision would redefine what an error rate means. Tools only — resources write no failure record of their own. A cancelled request keeps its own `info`, stack-free path whatever the contract declares. See `api-errors`.
220
222
 
221
223
  ### Errors, rate limits, HTTP client
222
224
 
223
225
  | Metric | Type | Unit | Attributes |
224
226
  |:-------|:-----|:-----|:-----------|
225
- | `mcp.errors.classified` | counter | `{errors}` | `mcp.error.classified_code` (JSON-RPC code), `mcp.error.category` (`upstream`/`server`/`client`, as in [Error category](#error-category)), `operation`, and `mcp.error.severity` when the failure's declared severity resolved |
227
+ | `mcp.errors.classified` | counter | `{errors}` | `mcp.error.classified_code` (JSON-RPC code), `mcp.error.category` (`upstream`/`server`/`client`, as in [Error category](#error-category)), `operation`, and `mcp.error.severity` when a tool failure's level resolved below `error` — a declared severity, or `notice` for an `invalid_arguments` / `client_capability_missing` refusal |
226
228
  | `mcp.ratelimit.rejections` | counter | `{rejections}` | — (the rate-limit key is caller-supplied and typically per-client, so it would materialize an unbounded series in the meter; per-key attribution lives on the span instead) |
227
229
  | `http.client.request.duration` | histogram | `s` | `http.request.method`, `server.address`, `http.response.status_code` (when > 0; absent on network errors before a response is received) |
228
230
 
@@ -245,17 +247,19 @@ Auto-registered when `process.memoryUsage` / `process.uptime` / `perf_hooks` are
245
247
 
246
248
  Every framework log record carries `requestId`, `traceId`, `spanId`, and `tenantId` from the request context, so every log line is searchable by trace. `@opentelemetry/instrumentation-pino` does not touch these records: it patches only a `pino` loaded after the SDK starts. To ship the records to the same backend as traces, set `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` (see Enabling export).
247
249
 
248
- For domain logging inside handlers, use `ctx.log` (`debug`/`info`/`notice`/`warning`/`error`) — auto-includes `requestId`, `traceId`, `tenantId`, `spanId`. The completion log emitted at the end of every handler carries a `metrics` payload, with fields tuned to each surface:
250
+ For domain logging inside handlers, use `ctx.log` (`debug`/`info`/`notice`/`warning`/`error`) — auto-includes `requestId`, `traceId`, `tenantId`, `spanId`. The completion log emitted at the end of every handler — at `info`, whatever the outcome — carries a `metrics` payload, with fields tuned to each surface:
249
251
 
250
252
  | Handler | Log message | `metrics` fields |
251
253
  |:--------|:------------|:-----------------|
252
254
  | Tool | `Tool execution finished.` | `durationMs`, `isSuccess`, `errorCode`, `inputBytes`, `outputBytes`, plus `partialSuccess` / `batchSucceeded` / `batchFailed` when the result is a partial-success batch |
253
255
  | Resource | `Resource read finished.` | `durationMs`, `isSuccess`, `errorCode`, `outputBytes`, `uri`, `mimeType` |
254
- | Prompt | `Prompt generation finished.` (or `failed.`) | `durationMs`, `isSuccess`, `errorCode`, `inputBytes`, `outputBytes`, `messageCount` |
256
+ | Prompt | `Prompt generation finished.` | `durationMs`, `isSuccess`, `errorCode`, `inputBytes` (0 for a prompt declaring no arguments), `outputBytes`, `messageCount` |
257
+
258
+ A failed tool call or prompt adds exactly one `Error in tool:<name>` / `Error in prompt:<name>` record. Each call — prompts included — logs under its own `requestId`, and the client receives that value as `data.requestId` on the call's error envelope, so a reported failure resolves to its records.
255
259
 
256
260
  ### Failed-call payloads
257
261
 
258
- Off by default. With `LOG_TOOL_FAILURE_PAYLOADS=true`, a failed tool call writes one more record right after its `Error in tool:<name>` record: message `Tool failure payload: <name>`, the same request context (`requestId`, `traceId`, `spanId`, `toolName`), and the same level, a declared `severity` included.
262
+ Off by default. With `LOG_TOOL_FAILURE_PAYLOADS=true`, a failed tool call writes one more record right after its `Error in tool:<name>` record: message `Tool failure payload: <name>`, the same request context (`requestId`, `traceId`, `spanId`, `toolName`), and the same level, a declared `severity` and the `notice` of an argument rejection included. A payload record below `MCP_LOG_LEVEL` is dropped with its `Error in tool:` record, so at `warning` or above an argument rejection writes neither.
259
263
 
260
264
  | Field | Content |
261
265
  |:------|:--------|
@@ -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.12"
7
+ version: "1.13"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -133,6 +133,8 @@ 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
+ A declared reason thrown without a hint — a bare `ctx.fail('reason')` or a service throw carrying `{ reason }` — comes back with the entry's `recovery` as `data.recovery.hint` and a `Recovery:` line in `content[]`, as in production. The one production field it leaves out is `data.requestId` (and the `request <id>` term closing `content[]`), since there is no real request; a test asserting the factory's envelope instead expects both. Calling `definition.handler(...)` directly returns the `McpError` exactly as the throw site built it — no fill, no request id.
137
+
136
138
  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
139
 
138
140
  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.
@@ -149,6 +151,7 @@ createMockContext({ tenantId: 'test-tenant' }) // explicit tenant
149
151
  createMockContext({ errors: myTool.errors }) // attaches typed ctx.fail keyed by the contract reasons
150
152
  createMockContext({ inputResponses: { confirm: { action: 'accept', content: { ok: true } } } }) // second round of a multi-round-trip handler
151
153
  createMockContext({ requestState: 'opaque-state' }) // seeds ctx.inputs.state()
154
+ createMockContext({ clientCapabilities: { roots: {} } }) // seeds ctx.clientCapabilities and filters inputResponses to declared kinds
152
155
  createMockContext({ requestId: 'my-id' }) // override request ID (default: 'test-request-id')
153
156
  createMockContext({ notifyResourceListChanged: () => {} }) // with resource-list change notifier
154
157
  createMockContext({ notifyResourceUpdated: (_uri) => {} }) // with resource update notifier
@@ -162,6 +165,7 @@ createMockContext({ uri: new URL('myscheme://item/123') }) // for resource han
162
165
  ```ts
163
166
  interface MockContextOptions<TErrors extends readonly ErrorContract[] | undefined> {
164
167
  auth?: AuthContext;
168
+ clientCapabilities?: ClientCapabilities;
165
169
  errors?: TErrors | undefined;
166
170
  inputResponses?: InputResponses | Record<string, unknown>;
167
171
  notifyPromptListChanged?: () => void;
@@ -181,6 +185,7 @@ interface MockContextOptions<TErrors extends readonly ErrorContract[] | undefine
181
185
  |:-------|:-------|
182
186
  | _(none)_ | Working `ctx.state` on tenant `'default'`; `ctx.inputs` is empty (first round) |
183
187
  | `auth` | Sets `ctx.auth` for scope-checking tests |
188
+ | `clientCapabilities` | Sets `ctx.clientCapabilities` (`undefined` when omitted) and applies the production filter to `inputResponses`: only the answers these capabilities cover reach `ctx.inputs` (elicit → `elicitation`, and `elicitation.form` when it carries `content`; sampling → `sampling`, and `sampling.tools` when it holds a `tool_use` / `tool_result` block; roots → `roots`). Omitted, every seeded response reaches `ctx.inputs`, so existing `{ inputResponses }` tests are unaffected. The mock's `ctx.requestInput` stays ungated either way |
184
189
  | `errors` | Attaches a typed `ctx.fail` against the contract — same wiring the production handler factory uses. Pass `myTool.errors` directly; the return type narrows to `HandlerContext<ReasonOf<…>>`, so the context is assignable to that definition's handler parameter. |
185
190
  | `inputResponses` | Seeds `ctx.inputs` with the responses a retried request would carry, keyed by the identifiers the handler's `ctx.requestInput(...)` assigned (see below) |
186
191
  | `notifyPromptListChanged` | Assigns `ctx.notifyPromptListChanged` for prompt-list change notification tests |
@@ -219,14 +224,14 @@ Reach for `createInMemoryStorage()` when a service takes a `StorageService` dire
219
224
 
220
225
  ### Mock inputs
221
226
 
222
- `ctx.requestInput` is the real implementation: it throws an `InputRequiredSignal` the production handler factories convert into an `input_required` result. In a unit test the handler is called directly, so that signal surfaces as a thrown value — which is exactly how you assert the first round.
227
+ `ctx.requestInput` is the real implementation: it throws an `InputRequiredSignal` the production handler factories convert into an `input_required` result. In a unit test the handler is called directly, so that signal surfaces as a thrown value — which is exactly how you assert the first round. The examples below drive `export_report` from `api-context` § *The shape of a multi-round-trip handler*, which asks for a format the caller left out:
223
228
 
224
229
  ```ts
225
230
  import { isInputRequiredSignal } from '@cyanheads/mcp-ts-core';
226
231
 
227
- it('asks for confirmation on the first round', async () => {
232
+ it('asks for the format on the first round', async () => {
228
233
  const ctx = createMockContext();
229
- await expect(myTool.handler(myTool.input.parse({ path: '/tmp/x' }), ctx))
234
+ await expect(exportReport.handler(exportReport.input.parse({ reportId: 'r1' }), ctx))
230
235
  .rejects.toSatisfy(isInputRequiredSignal);
231
236
  });
232
237
  ```
@@ -236,30 +241,57 @@ To assert on *what* was requested, use `expectInputRequired` from `/testing`. It
236
241
  ```ts
237
242
  import { createMockContext, expectInputRequired } from '@cyanheads/mcp-ts-core/testing';
238
243
 
239
- const asked = await expectInputRequired(() => myTool.handler(input, createMockContext()));
240
- expect(asked.inputRequests?.confirm?.method).toBe('elicitation/create');
244
+ const asked = await expectInputRequired(() => exportReport.handler(input, createMockContext()));
245
+ expect(asked.inputRequests?.format?.method).toBe('elicitation/create');
241
246
  ```
242
247
 
243
248
  Pass `asked.requestState` back as `createMockContext({ requestState })` when the handler reads state from the prior round. `inputResponses` drives the second round. `ctx.inputs.accepted(key, schema)` and `.view(key)` read it with the same helpers production uses, so a wrong response shape fails in the test:
244
249
 
245
250
  ```ts
246
- it('proceeds once the user accepts', async () => {
251
+ it('exports in the format the user picked', async () => {
247
252
  const ctx = createMockContext({
248
- inputResponses: { confirm: { action: 'accept', content: { confirm: true } } },
253
+ inputResponses: { format: { action: 'accept', content: { format: 'csv' } } },
249
254
  });
250
- await expect(myTool.handler(input, ctx)).resolves.toMatchObject({ deleted: '/tmp/x' });
255
+ await expect(exportReport.handler(input, ctx)).resolves.toMatchObject({ url: expect.any(String) });
251
256
  });
252
257
 
253
258
  it('stops when the user declines', async () => {
254
259
  const ctx = createMockContext({
255
- inputResponses: { confirm: { action: 'decline' } },
260
+ inputResponses: { format: { action: 'decline' } },
256
261
  });
257
- await expect(myTool.handler(input, ctx)).rejects.toThrow(McpError);
262
+ await expect(exportReport.handler(input, ctx)).rejects.toThrow(McpError);
258
263
  });
259
264
  ```
260
265
 
266
+ Seeding an answer this way is right for a handler that treats it as input. A consent gate does not: it acts only on a record it stored when it asked, so an answer seeded alone makes it ask again — see below.
267
+
261
268
  `ctx.inputs.dropped` is always `[]` on a mock context — the drop only happens in the SDK's wire decoding, so cover it in an integration test rather than a unit one.
262
269
 
270
+ Seed `clientCapabilities` to test what a client without a capability gets: a pre-answered `inputResponses` the declared capabilities do not cover — a kind never declared, a form answer (one carrying `content`) from a client that declared only `elicitation.url`, a tool-use sampling answer without `sampling.tools` — never reaches `ctx.inputs`, so the handler asks again. A handler that falls through when a capability is missing (`if (ctx.clientCapabilities?.roots) … else …`) is tested by seeding both shapes.
271
+
272
+ A consent gate that redeems a `ctx.state` record (see `api-context` § *Consent gates*) needs the record in the second round's storage, and each mock context has its own. Copy what round one stored into the round-two context. The record carries the caller, so seed the same `auth` (or none) on both:
273
+
274
+ ```ts
275
+ const accept = { confirm: { action: 'accept', content: { confirm: true } } };
276
+
277
+ const first = createMockContext();
278
+ const asked = await expectInputRequired(() => deletePath.handler(input, first));
279
+ const record = await first.state.get(`consent/${asked.requestState}`);
280
+
281
+ const second = createMockContext({ inputResponses: accept, requestState: asked.requestState });
282
+ await second.state.set(`consent/${asked.requestState}`, record);
283
+ await expect(deletePath.handler(input, second)).resolves.toEqual({ deleted: input.path });
284
+
285
+ // The record is spent: the same state again asks for a fresh confirmation.
286
+ await expect(deletePath.handler(input, second)).rejects.toSatisfy(isInputRequiredSignal);
287
+
288
+ // An answer with no record behind it asks as well — nothing was asked, so nothing was confirmed.
289
+ const unasked = createMockContext({ inputResponses: accept });
290
+ await expect(deletePath.handler(input, unasked)).rejects.toSatisfy(isInputRequiredSignal);
291
+ ```
292
+
293
+ A record the round-two context holds under another `auth`, or one written by another tool (its `operation` differs), asks again the same way — cover whichever of those the handler's binding is meant to catch.
294
+
263
295
  ### Mock logger
264
296
 
265
297
  `ctx.log` captures all log calls for inspection. Import `MockContextLogger` from `@cyanheads/mcp-ts-core/testing` and cast `ctx.log` to access the `.calls` array (the cast is necessary because `createMockContext` returns `Context`, which types `log` as `ContextLogger`):
@@ -4,7 +4,7 @@ description: >
4
4
  Cloudflare Workers deployment using `createWorkerHandler` from `@cyanheads/mcp-ts-core/worker`. Covers the full handler signature, binding types, CloudflareBindings extensibility, runtime compatibility guards, and wrangler.toml requirements.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.8"
7
+ version: "1.9"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -204,6 +204,8 @@ export function getServerConfig() {
204
204
 
205
205
  **`in-memory` storage is volatile.** Data stored with the `in-memory` provider is lost between cold starts and is not shared across Worker instances. Use `cloudflare-kv`, `cloudflare-r2`, or `cloudflare-d1` for any state that must persist or be shared.
206
206
 
207
+ **Multi-round-trip retries land on any isolate.** Each 2026-07-28 request builds its own `McpServer`, and the retry after an `input_required` result can be routed to a different isolate. Set `MCP_REQUEST_STATE_KEY` (≥ 32 bytes) as a Worker secret — it is a core binding, injected like the rest — so every isolate seals and verifies `requestState` with the same key, and keep a consent gate's record (`api-context` § *Consent gates*) in `cloudflare-d1`. `in-memory` loses it to the next isolate, so the retry asks again. Never `cloudflare-kv`: it is eventually consistent, so a retry served elsewhere may not see the record yet, and a deletion may not yet stop an immediate replay — it widens the window concurrent retries already have, since redeeming is not atomic on any provider until `ctx.state` gains a `take` ([#593](https://github.com/cyanheads/mcp-ts-core/issues/593)).
208
+
207
209
  **Node-only utilities throw in Workers.** `scheduler` (`node-cron`), `sanitizePath` (fs-based), and `filesystem` storage provider all throw `ConfigurationError` when called from a Worker. Guard with `runtimeCaps.isNode` or avoid entirely.
208
210
 
209
211
  **DataCanvas is unavailable in Workers.** DuckDB has no V8-isolate build, so `core.canvas` is always `undefined` on Workers. Setting `CANVAS_PROVIDER_TYPE=duckdb` (the only non-default value) in `wrangler.toml` triggers a fail-closed `ConfigurationError` at init time:
@@ -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.30"
7
+ version: "2.31"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -263,9 +263,9 @@ A reference tool is the surface's decoder ring: which codes exist, what they mea
263
263
 
264
264
  Tools that perform multi-step mutations (the Workflow shape) have two safety considerations beyond single-call tools. Both are about giving the agent — and the human behind it — a chance to catch a bad invocation before it commits.
265
265
 
266
- **Confirmation-gated destructive modes, with an annotation fallback.** When a workflow's `mode` parameter switches between safe and destructive arms (`draft` vs `send`, `plan` vs `apply`), gate the destructive arm on a confirmation the handler asks for via `ctx.requestInput(...)`, so a human approves before the irreversible step fires. The handler is re-entered with the answer on `ctx.inputs`; it does not `await` mid-call.
266
+ **Confirmation-gated destructive modes, with an annotation fallback.** When a workflow's `mode` parameter switches between safe and destructive arms (`draft` vs `send`, `plan` vs `apply`), gate the destructive arm on a confirmation the handler asks for via `ctx.requestInput(...)`, so a human approves before the irreversible step fires. The handler is re-entered with the answer on `ctx.inputs`; it does not `await` mid-call. The answer alone does not prove the prompt was shown — a client can send one unasked, and any `requestState` replays within its lifetime — so the gate stores the operation, the caller, the confirmed target, and a hash of what it holds in a `ctx.state` record keyed by a random id, sends only that id as `requestState`, and redeems the record before the destructive arm runs (`api-context` § *Consent gates*). Plan shared storage for that record when a 2026-07-28 retry can reach another instance (`filesystem`, `supabase`, or `cloudflare-d1` — not `cloudflare-kv`), and set `MCP_REQUEST_STATE_KEY` so a retry can only carry state the server minted. Redeeming is not atomic — concurrent retries on one id can each pass the gate until `ctx.state` gains an atomic `take` ([#593](https://github.com/cyanheads/mcp-ts-core/issues/593)) — so an arm that must not run twice is idempotent per record.
267
267
 
268
- The gate is always *reachable* — `ctx.requestInput` is present on every transport and both protocol revisions (2025-11-25 legacy, 2026-07-28 current) — but it is not always *answerable*: a client that never fulfils the `input_required` result simply doesn't retry, and the destructive step never runs. The same holds for a 2025-11-25 HTTP client when the server runs `MCP_SESSION_MODE=stateless`: the legacy round-trip shim still runs, but its capability gate refuses because the serving instance never processed `initialize` — the destructive step never fires. That is the safe outcome, but it makes the tool unusable for those clients, so a server built around such a gate declares `createApp({ sessionMode: { default: 'stateful', require: 'stateful' } })` and refuses to start stateless rather than degrading (`api-context` § `ctx.requestInput`). Keep `destructiveHint: true` in annotations so those clients' own approval flows still surface the risk. A decline is terminal — the handler fails the call rather than re-asking, which would loop until the round budget runs out. The handler shape is in `api-context` § *The shape of a multi-round-trip handler*.
268
+ The gate is always *reachable* — `ctx.requestInput` is present on every transport and both protocol revisions (2025-11-25 legacy, 2026-07-28 current) — but it is not always *answerable*: a client that never fulfils the `input_required` result simply doesn't retry, and — with the record pattern, which proceeds only on a record it redeemed — the destructive step never runs. The same holds for a 2025-11-25 HTTP client when the server runs `MCP_SESSION_MODE=stateless`: the legacy round-trip shim still runs, but its capability gate refuses because the serving instance never processed `initialize` — the destructive step never fires. That is the safe outcome, but it makes the tool unusable for those clients, so a server built around such a gate declares `createApp({ sessionMode: { default: 'stateful', require: 'stateful' } })` and refuses to start stateless rather than degrading (`api-context` § `ctx.requestInput`). Keep `destructiveHint: true` in annotations so those clients' own approval flows still surface the risk. A decline is terminal — the handler fails the call rather than re-asking, which would loop until the round budget runs out. `ctx.clientCapabilities` never decides whether to ask: a gate that skips its prompt when `elicitation` is undeclared is the bypass the gate exists to prevent.
269
269
 
270
270
  **Safe defaults on parameters that determine blast radius.** When a workflow accepts a parameter that controls how far-reaching a mutation is, default to the safer value. A bulk file-update tool defaulting `mode: 'preview'` (no writes) means a sloppy agent call shows a diff rather than blasting changes; an apply-plan tool defaulting `dryRun: true` means a misread plan previews rather than executes; an object-delete tool requiring an explicit `confirmCount` matching the result-set size means an unscoped query can't silently nuke a million rows. Agents that genuinely want the destructive behavior have to name it explicitly, which surfaces intent in the tool call and in logs.
271
271
 
@@ -423,7 +423,7 @@ Two params, two behaviors — keep them named distinctly:
423
423
 
424
424
  Errors are part of the tool's interface — design them during the design phase, not as an afterthought. Three aspects: **the contract** (which failures are public), **classification** (what error code), and **messaging** (what the LLM reads).
425
425
 
426
- **Declare a typed contract for domain failures.** When a tool has known failure modes the agent should plan around (`no_match`, `queue_full`, `vendor_down`), enumerate them as `errors: [{ reason, code, when, recovery, retryable? }]` on the definition. `recovery` is required metadata — the agent's next move when this failure fires (≥ 5 words, lint-validated; spread `ctx.recoveryFor('reason')` into the throw-site `data` to send it on the wire as `data.recovery.hint`). The framework types `ctx.fail(reason, …)` against the declared reason union (typos become TS errors) and auto-populates `data.reason` on the thrown error for stable observability. The error reaches clients with parity across both surfaces — `structuredContent.error` (Claude Code) and `content[]` text (Claude Desktop). Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble from anywhere and don't need to be enumerated. Mark an entry the service layer throws, rather than the handler, with `thrownBy: 'service'` so the conformance lint doesn't report it as a reason the handler never raises. See `api-errors` skill for the full pattern.
426
+ **Declare a typed contract for domain failures.** When a tool has known failure modes the agent should plan around (`no_match`, `queue_full`, `vendor_down`), enumerate them as `errors: [{ reason, code, when, recovery, retryable? }]` on the definition. `recovery` is required metadata — the agent's next move when this failure fires (≥ 5 words, lint-validated; the framework sends it on the wire as `data.recovery.hint` with any failure carrying that reason and no hint of its own). The framework types `ctx.fail(reason, …)` against the declared reason union (typos become TS errors) and auto-populates `data.reason` on the thrown error for stable observability. The error reaches clients with parity across both surfaces — `structuredContent.error` (Claude Code) and `content[]` text (Claude Desktop). Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble from anywhere and don't need to be enumerated. Mark an entry the service layer throws, rather than the handler, with `thrownBy: 'service'` so the conformance lint doesn't report it as a reason the handler never raises. See `api-errors` skill for the full pattern.
427
427
 
428
428
  **Classify errors by origin.** Different error sources need different codes and different recovery guidance. Map the failure modes for each tool during design:
429
429
 
@@ -702,7 +702,7 @@ Items without an `If …:` prefix apply to every design. Conditional items only
702
702
  - [ ] **If the server has workflow tools:** call-flow documented (upstream sequence + mode arms) in design doc's Workflow Analysis
703
703
  - [ ] **If state-aware procedural guidance adds value:** instruction tool considered with `nextToolSuggestions` pre-filled from diagnostics
704
704
  - [ ] **If any tool is config-gated:** nothing routes to it while the gate is off — recovery strings, notices, and `guidance` name a callable target or state the capability is unavailable, and structured follow-ups naming it are emitted only under the config that registers it
705
- - [ ] **If workflow tools have destructive modes:** destructive arm gated on a `ctx.requestInput` confirmation read back from `ctx.inputs`, with `destructiveHint` annotation so clients that never fulfil the round still surface the risk
705
+ - [ ] **If workflow tools have destructive modes:** destructive arm gated on a `ctx.requestInput` confirmation that redeems a `ctx.state` consent record bound to the operation, caller, and target (shared storage when retries can reach another instance, `MCP_REQUEST_STATE_KEY` set, an arm that must not run twice idempotent per record), with `destructiveHint` annotation so clients that never fulfil the round still surface the risk
706
706
  - [ ] **If any tool calls `ctx.requestInput`:** `createApp()` declares `sessionMode` with `require: 'stateful'`
707
707
  - [ ] **If any output carries text other people wrote:** those fields listed, `format()` quotes or fences free text and flattens CR/LF in inline slots, and the server instructions say the content is data
708
708
  - [ ] **If a parameter determines blast radius:** safe default set (e.g., `mode: 'preview'`, `dryRun: true`, `confirmCount` required)
@@ -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.17"
7
+ version: "2.18"
8
8
  audience: external
9
9
  type: debug
10
10
  ---
@@ -436,7 +436,7 @@ When a call surprises you — slow, hangs, returns terse output, surfaces an unh
436
436
 
437
437
  - **`content[]` is an array of blocks — read all of them, never just `content[0]`.** A success result is assembled as `[...ctx.content media blocks, ...the format()/JSON domain render, ...the enrichment trailer]`. Everything the handler put on `ctx.enrich` — empty-result notices, totals, query echoes, truncation disclosure — renders in that trailer, a **separate trailing block**, not inside the `format()` block. Quoting `content[0].text` and reporting those fields as absent from `content[]` is a false parity gap; the suggested fix (render them in `format()` too) would double-render them. Dump `.result.content` in full before claiming drift.
438
438
  - Tool domain errors return `{result: {content: [...], isError: true}}` — they live in `result`, not `error`. Check `isError`, not the JSON-RPC error field.
439
- - **Tool error code/reason** rides on `result.structuredContent.error.{code, message, data?.reason}` — inspect that, not just the text. `data` is only spread when the handler threw an `McpError` (or `ZodError`); plain `throw new Error(...)` won't populate `data.reason`. Use `ctx.fail`-thrown errors when the contract reason matters. The text in `result.content[0].text` mirrors the message, adds `Recovery: <hint>` when `data.recovery.hint` says something the message does not already say, and closes with `(reason <reason> · not retryable)` for whichever of `data.reason` / `data.retryable` is present — the numeric code stays JSON-only.
439
+ - **Tool error code/reason** rides on `result.structuredContent.error.{code, message, data?.reason}` — inspect that, not just the text. `data` carries what the handler threw as an `McpError` (or a `ZodError`'s issues) plus the framework's `data.requestId`; plain `throw new Error(...)` won't populate `data.reason`. Use `ctx.fail`-thrown errors when the contract reason matters — a declared reason arrives with its contract `recovery` as `data.recovery.hint` even when the throw site passed none. The text in `result.content[0].text` mirrors the message, adds `Recovery: <hint>` when `data.recovery.hint` says something the message does not already say, and closes with `(reason <reason> · not retryable · request <id>)` for whichever of `data.reason` / `data.retryable` / `data.requestId` is present — the numeric code stays JSON-only. The request id matches the `requestId` on that call's server log records.
440
440
  - **Resource errors** are JSON-RPC-level — they appear in the top-level `error.{code, data.reason}` field, not inside `result`. Resource handlers re-throw rather than producing an `isError` envelope.
441
441
  - JSON-RPC `error` only appears for protocol issues (bad session, malformed envelope, unknown method).
442
442
  - `mcp_call` already strips SSE framing. Pipe to `jq` for readability.
@@ -4,7 +4,7 @@ description: >
4
4
  Finalize documentation and project metadata for a ship-ready MCP server. Use after implementation is complete, tests pass, and devcheck is clean. Safe to run at any stage — each step checks current state and only acts on what still needs work.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.20"
7
+ version: "2.21"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -204,7 +204,7 @@ If the project ships as an `.mcpb` bundle for Claude Desktop (check for `manifes
204
204
  **`package.json` scripts:**
205
205
 
206
206
  - `bundle` — builds the `.mcpb` (`mcpb pack`, then `scripts/clean-mcpb.ts` prunes dev deps and strips dependency-shipped agent docs)
207
- - `lint:packaging` — validates `manifest.json` ↔ `server.json` env var consistency, version parity for `manifest.json`, the plugin manifests, and the README badge, and the Dockerfile build stage (run by `devcheck`, which gates the step on `manifest.json`, a plugin manifest, `.mcpbignore`, `README.md`, or `Dockerfile`)
207
+ - `lint:packaging` — validates `manifest.json` ↔ `server.json` env var consistency, version parity for `manifest.json`, the plugin manifests, and the README badge, and the Dockerfile's stages: a stage not pinned to `$BUILDPLATFORM` must not run JavaScript while building — no `bun run build`, `bun -e`, or script, and no `bun install` once `bunfig.toml` is in the stage (run by `devcheck`, which gates the step on `manifest.json`, a plugin manifest, `.mcpbignore`, `README.md`, or `Dockerfile`)
208
208
 
209
209
  **Cross-file consistency:**
210
210
 
@@ -4,7 +4,7 @@ description: >
4
4
  Ship a release end-to-end across every registry the project targets (npm, MCP Registry, GitHub Releases for `.mcpb` bundles, GHCR). Runs the final verification gate, fast-forwards `main` when the release rode a release PR, creates the annotated tag on the commit `main` now points at, pushes commits and tags, then publishes to each applicable destination. Assumes git wrapup (version bumps, changelog, commit stack — and in release PR mode, the pushed branch and open PR) is already complete — this skill is the post-wrapup merge + tag + publish workflow. Retries transient network failures on publish steps; halts with a partial-state report when retries are exhausted or the failure is terminal.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.22"
7
+ version: "2.23"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -68,7 +68,7 @@ The user fixes locally and re-invokes. On re-invocation, already-published desti
68
68
  Read `package.json` → capture `version`. Then use your git tools to verify:
69
69
 
70
70
  - **Working tree is clean** — no uncommitted changes
71
- - **The release commit is in the stack** — `git log -1 --format=%s` starts with `chore(release): <version>`, or, in gated release PR mode, `git log main..HEAD --format=%s` contains it with only the review pass's own commits above it (`release-pr-review` lands fixes as ordinary commits on top; the tag still goes on the tip). Any other commit above the release commit — new work, a second version — is a halt.
71
+ - **The release commit is in the stack** — `git log -1 --format=%s` starts with `chore(release): <version>`, or, in gated release PR mode, `git log main..HEAD --format=%s` contains it with only the review pass's own commits above it (`release-pr-review` lands its fixes, and any work the caller handed it to include, as ordinary commits on top; the tag still goes on the tip). Any other commit above the release commit — one the review pass did not land, a second version — is a halt.
72
72
  - **Current branch** — `main`, or `release/<version>` in release PR mode. Anything else, halt.
73
73
  - **Release PR mode:** `gh pr view --json number,state,headRefOid` shows the PR `OPEN` with `headRefOid` equal to local HEAD. A mismatch means the branch has commits the PR doesn't (or the reverse) — halt and report both SHAs. Keep `number` and `headRefOid`: the merge check (step 3) and the tag body (step 4) need them after the checkout has moved to `main`.
74
74
 
@@ -280,7 +280,7 @@ docker buildx build --platform linux/amd64,linux/arm64 \
280
280
  --push .
281
281
  ```
282
282
 
283
- The build stage in `Dockerfile` must carry `FROM --platform=$BUILDPLATFORM` (the templates ship it). Without it the non-native leg of the multi-arch build runs under QEMU, where bun >= 1.4 aborts inside `bun run build` with a JavaScriptCore allocator assertion (`qemu: uncaught target signal 6`, exit 134) and no image publishes for either architecture. npm, the MCP Registry, and the GitHub Release have all published by this step, so the recovery is a follow-up patch release rather than a retry — check the flag before building, not after.
283
+ No stage built for the target platform may run JavaScript: the non-native leg of the multi-arch build runs it under QEMU, where bun >= 1.4 aborts with a JavaScriptCore allocator assertion (`qemu: uncaught target signal 6`) and no image publishes for either architecture. That covers `bun run build`, and also a `bun install` that sees `bunfig.toml` — its security scanner runs as a Bun program and the install fails with `NoSecurityScanData`. The templates keep every such step in stages that start `FROM --platform=$BUILDPLATFORM` — the build stage, and a `deps` stage that cross-installs production dependencies with `--os`/`--cpu` and runs `scripts/install-otel.ts` — so the production stage's only Bun calls are `HEALTHCHECK` and `CMD`. `bun run lint:packaging` (check 15, part of `devcheck`) fails a Dockerfile that breaks this and names the line. npm, the MCP Registry, and the GitHub Release have all published by this step, so the recovery is a follow-up patch release rather than a retry — confirm the lint passes before building, not after.
284
284
 
285
285
  If the project uses a non-GHCR registry or a custom image name, respect the project's convention. If push fails with a 401/403, prompt the user to authenticate (`echo $GITHUB_TOKEN | docker login ghcr.io -u <OWNER> --password-stdin`) and retry. Halt on build failure or non-auth push failure.
286
286
 
@@ -4,7 +4,7 @@ description: >
4
4
  Review pass on an open release PR (`release/<version>` → `main`) — the step between `git-wrapup` and `release-and-publish` when a project releases in gated release PR mode. Reads the PR's commit range through the `code-simplifier` lens plus a correctness review, verifies whatever an automated reviewer left on the PR, lands fixes as ordinary commits on top of the release branch and pushes it, keeps the PR body in sync with what ships, and leaves one summary comment. The only agent role that both edits and commits — and it never rewrites pushed history, tags, merges, touches `main`, or publishes.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.6"
7
+ version: "1.7"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -21,7 +21,7 @@ Not for: PRs from outside contributors (those get a human reply, not a commit on
21
21
  - The PR is open, and its head SHA equals local HEAD
22
22
  - No tag `v<version>` exists yet — tagging is `release-and-publish`'s job, after this pass
23
23
 
24
- Verify all three in step 1; halt on any mismatch.
24
+ Verify all three in step 1; halt on any mismatch. The one exception to a clean tree: uncommitted work the caller explicitly hands over to ship in this release. Verify it against its description, commit it first as ordinary commits on top of the stack (step 5's conventions), add it to the changelog entry, and review it with the rest of the range. Any other uncommitted change is a halt.
25
25
 
26
26
  ## Steps
27
27
 
@@ -4,7 +4,7 @@ description: >
4
4
  Review an MCP server for common security gaps: LLM-facing surfaces as injection vector (tools, resources, prompts, descriptions), scope blast radius, destructive ops without consent, upstream auth shape, input sinks (URL / path / roots / shell / schema strictness / ReDoS), tenant isolation, leakage through errors and telemetry, unbounded resources, and HTTP-mode deployment surface. Use before a release, after a batch of handler changes, or when the user asks for a security review, audit, or hardening pass. Produces grouped findings and a numbered options list.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.11"
7
+ version: "1.12"
8
8
  audience: external
9
9
  type: audit
10
10
  ---
@@ -110,7 +110,7 @@ grep -rn "auth: \[" src/mcp-server/tools/definitions/
110
110
 
111
111
  #### Axis 3 — Destructive ops without a consent round
112
112
 
113
- `ctx.requestInput` moves consent off the LLM and onto the user: the handler returns an `input_required` result and only runs the side effect once it is re-entered with an accepted response. Destructive tools without that round trust the LLM not to be tricked.
113
+ `ctx.requestInput` moves consent off the LLM and onto the user: the handler returns an `input_required` result and only runs the side effect once it is re-entered and redeems the record it stored when it asked. An accepted response on `ctx.inputs` alone proves nothing — a client can send one on a call nothing prompted for. Destructive tools without that round trust the LLM not to be tricked.
114
114
 
115
115
  **Look in:** handlers with `destructiveHint: true` or side-effecting verbs in names (`delete_*`, `send_*`, `pay_*`, `publish_*`, `drop_*`).
116
116
 
@@ -125,11 +125,12 @@ grep -rnE "ctx\.requestInput|ctx\.inputs" src/mcp-server/tools/definitions/
125
125
  - The confirmation **response** is validated — `ctx.inputs.accepted(key, Schema)` with a schema, not the bare overload. The SDK never re-validates a response against the schema its request advertised, and the payload is LLM-mediated: "user confirmed" does not mean "user authored these exact fields."
126
126
  - A **declined or cancelled** response is terminal — checked via `ctx.inputs.view(key)` and thrown on, never re-asked (a re-ask loops until the round budget runs out) and never treated as consent.
127
127
  - Consent is scoped to the specific target (e.g., record ID rendered in the message), not a generic "proceed?"
128
- - Any `requestState` carried across rounds is integrity-protected if it influences authorization, resource access, or which target gets mutated. It round-trips through the client and comes back attacker-controlled; the SDK does not sign or verify it.
129
- - **A consent gate's state is server-issued and single-use.** A client can send `inputResponses` plus a `requestState` of its own on the very FIRST call — honored on 2025-era connections, even from a client that declared no `elicitation` — so a handler that only *compares* client-carried state against a fresh resolution deletes on a forged "accepted" answer without ever prompting. A signed state closes forgery but not replay within its TTL. Keep the confirmed target (plus a content hash, so a same-path swap is caught) in a server-side record keyed by a random id, send only the id, redeem it before anything else in the handler, and refuse an unknown, used, or expired id.
130
- - **The weak point is answerability, not availability.** `ctx.requestInput` is present on every transport and both protocol eras — the 2025-era shim issues the real `elicitation/create` round trip, the 2026-07-28 client fulfils the embedded request directly. A client that never retries simply leaves the destructive step un-run, which fails safe. Keep `destructiveHint: true` so client-side approval flows still surface the risk, and do not accept "proceed anyway when the round is unavailable" as a fallback. On a 2025-era connection whose client lacks the capability, `ctx.requestInput` throws `client_capability_missing` inside the handler; catching that to run the side effect is exactly this bypass — let it propagate.
128
+ - Where `requestState` influences authorization, resource access, or which target gets mutated, `MCP_REQUEST_STATE_KEY` is set (≥ 32 bytes, the same on every instance a retry can reach). The framework then seals the string a handler returns and the SDK rejects any other state before the handler runs; unset, the state round-trips through the client and comes back attacker-controlled.
129
+ - **A consent gate's state is server-issued and single-use.** The framework drops answers the client's declared capabilities do not cover, kind and mode, on both eras, so a client without `elicitation.form` — a URL-only client included — cannot pre-answer a form gate. A client that declared it still can: it may send `inputResponses` — and a `requestState` of its own, or one it was issued earlier — on the very first call, so a handler that only *compares* client-carried state against a fresh resolution deletes on an answer nobody was shown. A sealed state closes forgery but not replay within its 900 s lifetime. Keep what the prompt confirmed in a `ctx.state` record keyed by a random id — the operation (tool name, or the resource URI read), the caller (`ctx.auth` `clientId` and `sub`), the target, and a content hash so a same-path swap is caught — send only the id, redeem it before anything else in the handler, and ask again on an unknown, used, or expired id or on any field that differs from this call. Without the operation, an id minted by another gated tool or resource confirms this one; without the caller, another user in the same tenant redeems an id they were handed while `MCP_REQUEST_STATE_KEY` is unset. The record's storage is shared by every instance a 2026-07-28 retry can reach — `filesystem`, `supabase`, or `cloudflare-d1`, never `cloudflare-kv`, whose eventual consistency widens the race below.
130
+ - **Redeeming is not atomic.** Read-then-delete stops a sequential replay, but concurrent retries carrying one id can each read the record before any delete lands and each run the action. Until `ctx.state` gains an atomic `take` ([#593](https://github.com/cyanheads/mcp-ts-core/issues/593)), an action that must not repeat — a payment, a send, a publish — is idempotent per record (the record id as the upstream idempotency key), or the risk is accepted knowingly and recorded in the findings.
131
+ - **The weak point is answerability, not availability.** `ctx.requestInput` is present on every transport and both protocol eras — the 2025-era shim issues the real `elicitation/create` round trip, the 2026-07-28 client fulfils the embedded request directly. A client that never retries simply leaves the destructive step un-run, which fails safe. Keep `destructiveHint: true` so client-side approval flows still surface the risk, and do not accept "proceed anyway when the round is unavailable" as a fallback. On a 2025-era connection whose client lacks the capability, `ctx.requestInput` throws `client_capability_missing` inside the handler; catching that to run the side effect is exactly this bypass — let it propagate. So is skipping the prompt because `ctx.clientCapabilities` lacks `elicitation`: that property decides whether to ask for optional context, never whether consent is needed.
131
132
 
132
- **Smell:** `destructiveHint: true` file with no `ctx.requestInput` in it. Or `ctx.inputs.accepted('confirm')` with no schema argument — the content could be anything. Or a handler that re-issues the same request after a `decline`.
133
+ **Smell:** `destructiveHint: true` file with no `ctx.requestInput` in it. Or `ctx.inputs.accepted('confirm')` with no schema argument — the content could be anything. Or a gate that proceeds on `ctx.inputs` without redeeming a `ctx.state` record, whose record omits the operation or the caller, or that compares a target carried in `requestState`. Or a non-repeatable action behind a gate with no idempotency key. Or a handler that re-issues the same request after a `decline`.
133
134
 
134
135
  #### Axis 4 — Upstream auth shape
135
136
 
@@ -343,7 +344,7 @@ End with:
343
344
  - [ ] `fuzzTool` started in parallel
344
345
  - [ ] Axis 1 — LLM-facing surfaces (tool / resource / prompt output + descriptions) framed and static
345
346
  - [ ] Axis 2 — scope granularity audited
346
- - [ ] Axis 3 — destructive ops verified to gate on a `ctx.requestInput` round, the response schema-validated, decline/cancel terminal
347
+ - [ ] Axis 3 — destructive ops verified to gate on a `ctx.requestInput` round that redeems a `ctx.state` record bound to the operation, caller, and target, the response schema-validated, decline/cancel terminal, non-repeatable actions idempotent per record, `MCP_REQUEST_STATE_KEY` set where state drives a mutation
347
348
  - [ ] Axis 4 — upstream auth + token passthrough reviewed
348
349
  - [ ] Axis 5 — input sinks (URL / path / roots / shell / proto / schema strictness / ReDoS) checked
349
350
  - [ ] Axis 6 — tenant isolation: module-scope state swept
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyanheads/mcp-ts-core",
3
- "version": "0.13.9",
3
+ "version": "0.13.10",
4
4
  "mcpName": "io.github.cyanheads/mcp-ts-core",
5
5
  "description": "Agent-native TypeScript framework for MCP servers. Includes runtime infrastructure and agent skills for building, testing, and shipping servers.",
6
6
  "files": [
@@ -16,6 +16,7 @@
16
16
  "scripts/clean-mcpb.ts",
17
17
  "scripts/clean.ts",
18
18
  "scripts/devcheck.ts",
19
+ "scripts/install-otel.ts",
19
20
  "scripts/lint-mcp.ts",
20
21
  "scripts/lint-packaging.ts",
21
22
  "scripts/list-skills.ts",
@@ -198,7 +199,7 @@
198
199
  "devDependencies": {
199
200
  "@biomejs/biome": "2.5.14",
200
201
  "@cloudflare/vitest-pool-workers": "^0.22.0",
201
- "@cloudflare/workers-types": "5.20260922.1",
202
+ "@cloudflare/workers-types": "5.20260924.1",
202
203
  "@duckdb/node-api": "^1.5.5-r.5",
203
204
  "@hono/otel": "^1.1.2",
204
205
  "@modelcontextprotocol/client": "^2.1.0",
@@ -292,7 +293,7 @@
292
293
  "@hono/node-server": "^2.1.1",
293
294
  "@modelcontextprotocol/server": "^2.1.0",
294
295
  "@opentelemetry/api": "^1.9.1",
295
- "hono": "^4.13.8",
296
+ "hono": "^4.13.9",
296
297
  "jose": "^6.2.12",
297
298
  "pino": "^10.3.1",
298
299
  "zod": "^4.6.5"
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * @fileoverview Installs the OpenTelemetry packages the framework loads at
4
+ * runtime into a production dependency tree — the Dockerfile's OTel step.
5
+ *
6
+ * The list is every `peerDependencies` entry named `@opentelemetry/*`, plus
7
+ * `@hono/otel`, in `@cyanheads/mcp-ts-core/package.json` resolved from the
8
+ * project root: the installed framework in a server, the package itself (by
9
+ * self-reference) in the framework. Each package moves into the project's
10
+ * `dependencies` at its declared peer range, out of `peerDependencies`,
11
+ * `peerDependenciesMeta`, and `devDependencies`, so `--omit=peer` keeps it
12
+ * while every other optional peer stays out. Then `bun install --omit=dev
13
+ * --omit=peer --ignore-scripts` runs with every argument given to this script
14
+ * appended, under the project's `bunfig.toml`, and its exit code is this
15
+ * script's. Not `--production`: it implies `--frozen-lockfile`, which fails
16
+ * once the manifest gains packages the lockfile lacks.
17
+ *
18
+ * @example
19
+ * // In a Dockerfile stage on $BUILDPLATFORM, cross-installing for the target:
20
+ * // bun scripts/install-otel.ts --os=linux --cpu=x64
21
+ * @module scripts/install-otel
22
+ */
23
+ import { spawnSync } from 'node:child_process';
24
+ import { readFileSync, writeFileSync } from 'node:fs';
25
+ import { createRequire } from 'node:module';
26
+ import { join, resolve } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ interface Manifest {
30
+ dependencies?: Record<string, string>;
31
+ devDependencies?: Record<string, string>;
32
+ peerDependencies?: Record<string, string>;
33
+ peerDependenciesMeta?: Record<string, unknown>;
34
+ }
35
+
36
+ /** The OTel optional peers a framework manifest declares, keyed by name, at their declared ranges. */
37
+ export function otelPeers(framework: Manifest): Record<string, string> {
38
+ return Object.fromEntries(
39
+ Object.entries(framework.peerDependencies ?? {}).filter(
40
+ ([name]) => name.startsWith('@opentelemetry/') || name === '@hono/otel',
41
+ ),
42
+ );
43
+ }
44
+
45
+ function main(args: string[]): number {
46
+ const manifestPath = join(process.cwd(), 'package.json');
47
+ const frameworkPath = createRequire(manifestPath).resolve('@cyanheads/mcp-ts-core/package.json');
48
+ const peers = otelPeers(JSON.parse(readFileSync(frameworkPath, 'utf8')) as Manifest);
49
+ const names = Object.keys(peers);
50
+ if (names.length === 0) {
51
+ console.error(
52
+ `install-otel: ${frameworkPath} declares no @opentelemetry/* or @hono/otel peerDependencies — nothing to install.`,
53
+ );
54
+ return 1;
55
+ }
56
+
57
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Manifest;
58
+ manifest.dependencies = { ...manifest.dependencies, ...peers };
59
+ for (const name of names) {
60
+ delete manifest.peerDependencies?.[name];
61
+ delete manifest.peerDependenciesMeta?.[name];
62
+ delete manifest.devDependencies?.[name];
63
+ }
64
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
65
+ console.log(
66
+ `install-otel: ${names.length} OpenTelemetry packages from ${frameworkPath}: ` +
67
+ names.map((name) => `${name}@${peers[name]}`).join(' '),
68
+ );
69
+
70
+ const install = spawnSync(
71
+ 'bun',
72
+ ['install', '--omit=dev', '--omit=peer', '--ignore-scripts', ...args],
73
+ { stdio: 'inherit' },
74
+ );
75
+ if (install.error) {
76
+ console.error(`install-otel: could not run bun install: ${install.error.message}`);
77
+ return 1;
78
+ }
79
+ return install.status ?? 1;
80
+ }
81
+
82
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
83
+ process.exit(main(process.argv.slice(2)));
84
+ }