@cyanheads/mcp-ts-core 0.13.9 → 0.13.11

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 (192) hide show
  1. package/AGENTS.md +41 -15
  2. package/CLAUDE.md +41 -15
  3. package/README.md +37 -11
  4. package/biome.json +1 -1
  5. package/changelog/0.13.x/0.13.10.md +118 -0
  6. package/changelog/0.13.x/0.13.11.md +113 -0
  7. package/dist/cli/app-render.d.ts +11 -0
  8. package/dist/cli/app-render.d.ts.map +1 -0
  9. package/dist/cli/app-render.js +150 -0
  10. package/dist/cli/app-render.js.map +1 -0
  11. package/dist/cli/init.d.ts +2 -1
  12. package/dist/cli/init.d.ts.map +1 -1
  13. package/dist/cli/init.js +10 -2
  14. package/dist/cli/init.js.map +1 -1
  15. package/dist/config/index.d.ts +3 -0
  16. package/dist/config/index.d.ts.map +1 -1
  17. package/dist/config/index.js +11 -0
  18. package/dist/config/index.js.map +1 -1
  19. package/dist/core/app.d.ts.map +1 -1
  20. package/dist/core/app.js +15 -1
  21. package/dist/core/app.js.map +1 -1
  22. package/dist/core/context.d.ts +92 -22
  23. package/dist/core/context.d.ts.map +1 -1
  24. package/dist/core/context.js +65 -16
  25. package/dist/core/context.js.map +1 -1
  26. package/dist/core/index.d.ts +1 -1
  27. package/dist/core/index.d.ts.map +1 -1
  28. package/dist/core/index.js.map +1 -1
  29. package/dist/core/worker.d.ts +6 -0
  30. package/dist/core/worker.d.ts.map +1 -1
  31. package/dist/core/worker.js +1 -0
  32. package/dist/core/worker.js.map +1 -1
  33. package/dist/linter/rules/error-contract-rules.d.ts +3 -44
  34. package/dist/linter/rules/error-contract-rules.d.ts.map +1 -1
  35. package/dist/linter/rules/error-contract-rules.js +8 -144
  36. package/dist/linter/rules/error-contract-rules.js.map +1 -1
  37. package/dist/linter/rules/index.d.ts +1 -1
  38. package/dist/linter/rules/index.d.ts.map +1 -1
  39. package/dist/linter/rules/index.js +1 -1
  40. package/dist/linter/rules/index.js.map +1 -1
  41. package/dist/linter/rules/resource-rules.d.ts.map +1 -1
  42. package/dist/linter/rules/resource-rules.js +1 -2
  43. package/dist/linter/rules/resource-rules.js.map +1 -1
  44. package/dist/linter/rules/tool-rules.d.ts.map +1 -1
  45. package/dist/linter/rules/tool-rules.js +1 -2
  46. package/dist/linter/rules/tool-rules.js.map +1 -1
  47. package/dist/mcp-server/handlerContext.d.ts +26 -13
  48. package/dist/mcp-server/handlerContext.d.ts.map +1 -1
  49. package/dist/mcp-server/handlerContext.js +32 -17
  50. package/dist/mcp-server/handlerContext.js.map +1 -1
  51. package/dist/mcp-server/inputRequired.d.ts +120 -8
  52. package/dist/mcp-server/inputRequired.d.ts.map +1 -1
  53. package/dist/mcp-server/inputRequired.js +177 -12
  54. package/dist/mcp-server/inputRequired.js.map +1 -1
  55. package/dist/mcp-server/prompts/prompt-registration.d.ts +10 -2
  56. package/dist/mcp-server/prompts/prompt-registration.d.ts.map +1 -1
  57. package/dist/mcp-server/prompts/prompt-registration.js +49 -11
  58. package/dist/mcp-server/prompts/prompt-registration.js.map +1 -1
  59. package/dist/mcp-server/resources/resource-registration.d.ts +4 -2
  60. package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
  61. package/dist/mcp-server/resources/resource-registration.js +6 -4
  62. package/dist/mcp-server/resources/resource-registration.js.map +1 -1
  63. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts +4 -3
  64. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
  65. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +60 -21
  66. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
  67. package/dist/mcp-server/server.d.ts +9 -0
  68. package/dist/mcp-server/server.d.ts.map +1 -1
  69. package/dist/mcp-server/server.js +14 -13
  70. package/dist/mcp-server/server.js.map +1 -1
  71. package/dist/mcp-server/tools/tool-registration.d.ts +7 -3
  72. package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
  73. package/dist/mcp-server/tools/tool-registration.js +9 -5
  74. package/dist/mcp-server/tools/tool-registration.js.map +1 -1
  75. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +17 -9
  76. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  77. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +91 -50
  78. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  79. package/dist/mcp-server/transports/http/httpErrorHandler.d.ts.map +1 -1
  80. package/dist/mcp-server/transports/http/httpErrorHandler.js +7 -1
  81. package/dist/mcp-server/transports/http/httpErrorHandler.js.map +1 -1
  82. package/dist/services/mirror/sqlite/handle.d.ts +11 -5
  83. package/dist/services/mirror/sqlite/handle.d.ts.map +1 -1
  84. package/dist/services/mirror/sqlite/handle.js +46 -8
  85. package/dist/services/mirror/sqlite/handle.js.map +1 -1
  86. package/dist/services/mirror/sqlite/sqliteMirrorStore.d.ts +6 -1
  87. package/dist/services/mirror/sqlite/sqliteMirrorStore.d.ts.map +1 -1
  88. package/dist/services/mirror/sqlite/sqliteMirrorStore.js.map +1 -1
  89. package/dist/testing/apps/browser.d.ts +27 -0
  90. package/dist/testing/apps/browser.d.ts.map +1 -0
  91. package/dist/testing/apps/browser.js +162 -0
  92. package/dist/testing/apps/browser.js.map +1 -0
  93. package/dist/testing/apps/cdp-pipe.d.ts +79 -0
  94. package/dist/testing/apps/cdp-pipe.d.ts.map +1 -0
  95. package/dist/testing/apps/cdp-pipe.js +200 -0
  96. package/dist/testing/apps/cdp-pipe.js.map +1 -0
  97. package/dist/testing/apps/csp.d.ts +17 -0
  98. package/dist/testing/apps/csp.d.ts.map +1 -0
  99. package/dist/testing/apps/csp.js +50 -0
  100. package/dist/testing/apps/csp.js.map +1 -0
  101. package/dist/testing/apps/host-pages.d.ts +47 -0
  102. package/dist/testing/apps/host-pages.d.ts.map +1 -0
  103. package/dist/testing/apps/host-pages.js +138 -0
  104. package/dist/testing/apps/host-pages.js.map +1 -0
  105. package/dist/testing/apps/index.d.ts +167 -0
  106. package/dist/testing/apps/index.d.ts.map +1 -0
  107. package/dist/testing/apps/index.js +36 -0
  108. package/dist/testing/apps/index.js.map +1 -0
  109. package/dist/testing/apps/partial-json.d.ts +14 -0
  110. package/dist/testing/apps/partial-json.d.ts.map +1 -0
  111. package/dist/testing/apps/partial-json.js +71 -0
  112. package/dist/testing/apps/partial-json.js.map +1 -0
  113. package/dist/testing/apps/run.d.ts +13 -0
  114. package/dist/testing/apps/run.d.ts.map +1 -0
  115. package/dist/testing/apps/run.js +659 -0
  116. package/dist/testing/apps/run.js.map +1 -0
  117. package/dist/testing/index.d.ts +17 -2
  118. package/dist/testing/index.d.ts.map +1 -1
  119. package/dist/testing/index.js +21 -7
  120. package/dist/testing/index.js.map +1 -1
  121. package/dist/types-global/errors.d.ts +18 -15
  122. package/dist/types-global/errors.d.ts.map +1 -1
  123. package/dist/utils/index.d.ts +1 -1
  124. package/dist/utils/index.d.ts.map +1 -1
  125. package/dist/utils/index.js +1 -1
  126. package/dist/utils/index.js.map +1 -1
  127. package/dist/utils/internal/error-handler/errorHandler.d.ts +5 -4
  128. package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
  129. package/dist/utils/internal/error-handler/errorHandler.js +9 -7
  130. package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
  131. package/dist/utils/internal/error-handler/types.d.ts +3 -1
  132. package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
  133. package/dist/utils/internal/logger.d.ts +21 -1
  134. package/dist/utils/internal/logger.d.ts.map +1 -1
  135. package/dist/utils/internal/logger.js +31 -14
  136. package/dist/utils/internal/logger.js.map +1 -1
  137. package/dist/utils/internal/performance.d.ts +7 -3
  138. package/dist/utils/internal/performance.d.ts.map +1 -1
  139. package/dist/utils/internal/performance.js +16 -9
  140. package/dist/utils/internal/performance.js.map +1 -1
  141. package/dist/utils/internal/telemetryMessages.d.ts +0 -1
  142. package/dist/utils/internal/telemetryMessages.d.ts.map +1 -1
  143. package/dist/utils/internal/telemetryMessages.js +0 -1
  144. package/dist/utils/internal/telemetryMessages.js.map +1 -1
  145. package/dist/utils/security/sanitization.d.ts +16 -13
  146. package/dist/utils/security/sanitization.d.ts.map +1 -1
  147. package/dist/utils/security/sanitization.js +74 -33
  148. package/dist/utils/security/sanitization.js.map +1 -1
  149. package/dist/utils/telemetry/attributes.d.ts +12 -5
  150. package/dist/utils/telemetry/attributes.d.ts.map +1 -1
  151. package/dist/utils/telemetry/attributes.js +12 -5
  152. package/dist/utils/telemetry/attributes.js.map +1 -1
  153. package/framework-skills/add-app-tool/SKILL.md +2 -1
  154. package/framework-skills/add-service/SKILL.md +3 -12
  155. package/framework-skills/add-test/SKILL.md +20 -17
  156. package/framework-skills/add-tool/SKILL.md +30 -34
  157. package/framework-skills/api-config/SKILL.md +12 -2
  158. package/framework-skills/api-context/SKILL.md +156 -41
  159. package/framework-skills/api-errors/SKILL.md +43 -47
  160. package/framework-skills/api-linter/SKILL.md +5 -29
  161. package/framework-skills/api-mirror/SKILL.md +4 -3
  162. package/framework-skills/api-telemetry/SKILL.md +16 -10
  163. package/framework-skills/api-testing/SKILL.md +47 -11
  164. package/framework-skills/api-utils/SKILL.md +3 -3
  165. package/framework-skills/api-workers/SKILL.md +3 -1
  166. package/framework-skills/code-simplifier/SKILL.md +2 -2
  167. package/framework-skills/design-mcp-server/SKILL.md +5 -5
  168. package/framework-skills/field-test/SKILL.md +53 -8
  169. package/framework-skills/git-wrapup/SKILL.md +2 -2
  170. package/framework-skills/orchestrations/SKILL.md +1 -1
  171. package/framework-skills/orchestrations/workflows/greenfield-build.md +2 -2
  172. package/framework-skills/polish-docs-meta/SKILL.md +4 -3
  173. package/framework-skills/polish-docs-meta/references/server-json.md +24 -26
  174. package/framework-skills/release-and-publish/SKILL.md +3 -3
  175. package/framework-skills/release-pr-review/SKILL.md +6 -6
  176. package/framework-skills/security-pass/SKILL.md +8 -7
  177. package/framework-skills/setup/SKILL.md +2 -2
  178. package/package.json +38 -22
  179. package/scripts/devcheck.ts +43 -21
  180. package/scripts/install-otel.ts +84 -0
  181. package/scripts/lint-packaging.ts +279 -28
  182. package/scripts/prune-musl-packages.ts +146 -0
  183. package/templates/.env.example +2 -0
  184. package/templates/.github/workflows/codeql.yml +10 -1
  185. package/templates/AGENTS.md +5 -4
  186. package/templates/CLAUDE.md +5 -4
  187. package/templates/Dockerfile +76 -50
  188. package/templates/_.dockerignore +3 -5
  189. package/templates/_.gitignore +4 -4
  190. package/templates/package.json +4 -4
  191. package/templates/server.json +7 -21
  192. package/templates/src/mcp-server/tools/definitions/echo.tool.ts +3 -8
@@ -4,7 +4,7 @@ description: >
4
4
  API reference for all utilities exported from `@cyanheads/mcp-ts-core/utils`. Use when looking up utility method signatures, options, peer dependencies, or usage patterns.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.14"
7
+ version: "2.15"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -85,7 +85,7 @@ The `utils` export includes two type guards. The full set of guards lives in the
85
85
  | Export | API | Notes |
86
86
  |:-------|:----|:------|
87
87
  | `Logger` | Class | The `Logger` class itself. Use `Logger.getInstance()` if needed; most consumers use the `logger` singleton. |
88
- | `logger` | `Logger` instance (wraps Pino). `.debug(msg, ctx?)` `.info(msg, ctx?)` `.notice(msg, ctx?)` `.warning(msg, ctx?)` `.error(msg, errorOrCtx, ctx?)` `.crit(msg, errorOrCtx, ctx?)` `.alert(msg, errorOrCtx, ctx?)` `.emerg(msg, errorOrCtx, ctx?)` `.fatal(msg, errorOrCtx, ctx?)` | Global structured logger. Use `ctx.log` in handlers instead. `logger` is for lifecycle/background contexts (startup, shutdown, `setup()`). Auto-redacts sensitive fields. The context's `extra` bag is flattened into the record, but a canonical field the context carries (`requestId`, `timestamp`, `traceId`, `spanId`, `sessionId`, `tenantId`, `operation`) always wins over an `extra` key of the same name. Records logged before the framework initializes the logger — anything in `setup()` — are held in a 250-record buffer and replayed once the sinks exist, filtered against the level the logger starts with. **Note:** `.error()` and higher accept `(msg, Error, ctx?)` or `(msg, ctx?)` — the second arg is overloaded. `.fatal()` is an alias for `.emerg()`. Full RFC 5424 severity set. |
88
+ | `logger` | `Logger` instance (wraps Pino). `.debug(msg, ctx?)` `.info(msg, ctx?)` `.notice(msg, ctx?)` `.warning(msg, ctx?)` `.error(msg, errorOrCtx, ctx?)` `.crit(msg, errorOrCtx, ctx?)` `.alert(msg, errorOrCtx, ctx?)` `.emerg(msg, errorOrCtx, ctx?)` `.fatal(msg, errorOrCtx, ctx?)` `.isLevelEnabled(level) -> boolean` | Global structured logger. Use `ctx.log` in handlers instead. `logger` is for lifecycle/background contexts (startup, shutdown, `setup()`). Auto-redacts sensitive fields. The context's `extra` bag is flattened into the record, but a canonical field the context carries (`requestId`, `timestamp`, `traceId`, `spanId`, `sessionId`, `tenantId`, `operation`) always wins over an `extra` key of the same name. Records logged before the framework initializes the logger — anything in `setup()` — are held in a 250-record buffer and replayed once the sinks exist, filtered against the level the logger starts with. `isLevelEnabled(level)` is that filter: `true` when a record at `level` would be written, compared on the RFC 5424 order of all eight levels (a `notice` level drops `info` though pino emits both at `info`, a `crit` level drops `error`), and `true` for every level before initialization. The `ctx.log` mirror to the client is gated by the same check. **Note:** `.error()` and higher accept `(msg, Error, ctx?)` or `(msg, ctx?)` — the second arg is overloaded. `.fatal()` is an alias for `.emerg()`. Full RFC 5424 severity set. |
89
89
  | `McpLogLevel` | Type | Log level union type for typing level variables. |
90
90
 
91
91
  ---
@@ -175,7 +175,7 @@ Helper API only. For the catalog of what the framework auto-emits (span names, m
175
175
 
176
176
  ### `telemetry/attributes`
177
177
 
178
- MCP-specific `ATTR_*` constant exports for span and metric attributes. Covers: code execution (`code.function.name`, `code.namespace`), MCP tool execution (name, input/output bytes, duration, success, error code, error category, partial success, batch succeeded/failed counts), MCP resource (URI, name, MIME type, size, duration, success, error code), MCP request context (tenant ID, client ID), MCP session events, MCP storage, GenAI semantic conventions, speech, graph, auth, task, and error classification attributes.
178
+ MCP-specific `ATTR_*` constant exports for span and metric attributes. Covers: code execution (`code.function.name`, `code.namespace`), MCP tool execution (name, input/output bytes, duration, success, error code, error category, partial success, batch succeeded/failed counts), MCP resource (URI, the uncut URI length when the recorded URI was capped, name, MIME type, size, duration, success, error code), MCP request context (tenant ID, client ID), MCP session events, MCP storage, GenAI semantic conventions, speech, graph, auth, task, and error classification attributes.
179
179
 
180
180
  Batch/partial success attributes (`mcp.tool.partial_success`, `mcp.tool.batch.succeeded_count`, `mcp.tool.batch.failed_count`) are set automatically by the framework when a tool handler returns a result containing a non-empty `failed` array — matching the batch response pattern from the design skill. A tool whose `output` is built with `partialResultSchema()` is read under its `failedKey`/`succeededKey` instead, including after `.extend()`, `.pick()`, `.omit()`, or a `.shape` spread.
181
181
 
@@ -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
  Cleanup pass that edits the working tree — over a session's uncommitted changes, or a named path or whole codebase. Reads `git diff` (or the named target) and simplifies, consolidates, and aligns code with the existing codebase — modernize syntax, cut unnecessary complexity and slop, consolidate duplicated logic, catch efficiency issues. Not a bug hunt: defects are reported, not fixed. Use after a substantive working session, or when asked to clean up, simplify, reduce slop, consolidate, modernize, tighten up, de-slop, or scan a codebase. For `@cyanheads/mcp-ts-core` projects, includes specific transformations for tool/resource/prompt definitions, the ctx pattern, error factories, and framework idioms.
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
  ---
@@ -75,7 +75,7 @@ Evaluate the changes across these dimensions. Not every dimension applies to eve
75
75
  - **Gate** — `bun run devcheck` plus the test suite (`bun run test`) is the project gate in Phase 2 step 4 and Phase 4 step 5.
76
76
  - **Framework-provided utilities** — Before hand-rolling, check `src/utils/` and `src/errors/` in the project and `node_modules/@cyanheads/mcp-ts-core/` for framework exports: pagination helpers, schema builders, retry primitives, and the `ATTR_*` OTel attribute constants are framework-provided. Raw OTel attribute keys should be `ATTR_*` imports from `@cyanheads/mcp-ts-core/utils`.
77
77
  - **Error throwing patterns** — Prefer framework error factories (`McpError`, `validationError`, `notFound`, `httpErrorFromResponse`) over raw `throw new Error()`. Tool handlers should throw — the framework catches, classifies, and instruments.
78
- - **Error codes** — `InvalidParams` only for malformed JSON-RPC params shape. `ValidationError` for domain validation. `NotFound` for missing entities. Don't conflate them.
78
+ - **Error codes** — `InvalidParams` for input the schema rejects (the framework raises it before the handler runs) and for an upstream 400 mapped by `httpErrorFromResponse`. `ValidationError` for a business rule the schema cannot express. `NotFound` for missing entities. Don't conflate them.
79
79
  - **Ctx usage** — Use `ctx.log`, `ctx.state`, `ctx.enrich` — don't reach for global loggers or request-scoped storage directly. The `ctx` pattern carries tenant scope and OTel context.
80
80
  - **Zod schemas** — Every tool input/output field needs `.describe()`. Zod 4 requires `z.record(z.string(), z.string())` not `z.record(z.string())`. Use `.optional()` rather than `.nullish()` unless null is semantically distinct from absent.
81
81
  - **Tool annotations** — `readOnlyHint`, `idempotentHint`, `openWorldHint` should reflect reality. A read-only tool with `readOnlyHint: false` gives clients the wrong picture.
@@ -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)
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: field-test
3
3
  description: >
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.
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, renders app tools' views in a headless MCP Apps host, 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.19"
8
8
  audience: external
9
9
  type: debug
10
10
  ---
@@ -413,6 +413,7 @@ Treat any hit as a `ux` finding in the report. The authoring rule lives under *T
413
413
  | Tool declared an `errors: [...]` contract | Error contract (tool): trigger ≥1 declared failure mode. Verify `result.structuredContent.error.code` matches the contract entry, `result.structuredContent.error.data.reason` is the declared reason (only present when the handler threw an `McpError` — `ctx.fail` always does, plain `throw new Error(...)` does not), and `content[0].text` is actionable. Reasons declared but unreachable from any input are dead contract entries. |
414
414
  | Resource declared an `errors: [...]` contract | Error contract (resource): trigger ≥1 declared failure mode by reading a URI that exercises it. Resources re-throw errors at the JSON-RPC level — verify `error.code` matches the contract entry and `error.data.reason` is the declared reason. (Resources don't use the `result.isError` envelope — they fail the request itself.) |
415
415
  | Mutator (write/update/delete/append/patch verbs, or `destructiveHint: true`) | Mutator response observability: run an intentionally-ambiguous input (typo path, wrong ID, already-deleted target). Confirm the response carries enough state (pre/post values, state-change discriminator) for the agent to detect intent-effect divergence without re-fetching. |
416
+ | `_meta.ui.resourceUri` on the tool (an app tool) | View rendering: Step 6. The curl calls see only the `format()` text and the view's raw HTML; whether the view runs is visible only when it is rendered. |
416
417
 
417
418
  **Resources.** Happy path, not-found URI (use a syntactically valid but non-existent ID — e.g., substitute a fake ID into the URI template), `list` if defined, pagination if used.
418
419
  **Prompts.** Happy path, defaults omitted, skim message quality.
@@ -430,28 +431,71 @@ Use `TaskCreate` — one task per definition. Mark complete as you go. Don't bat
430
431
 
431
432
  For each call, capture: input sent, the `⏱` line (bytes, split, ms), response (trim huge payloads to files), whether `isError: true` appeared, anything surprising (slow response, parity drift, unhelpful text, crash).
432
433
 
433
- When a call surprises you — slow, hangs, returns terse output, surfaces an unhelpful error — run `. /tmp/<project-name>-field-test-<ID>.sh && mcp_log <log>` to tail the server log. The pino startup banner, request handler errors, upstream API call traces, and rate-limit warnings all land in the per-server log (read via `mcp_log`) rather than coming back through `mcp_call`. Don't guess at runtime behavior from response text alone.
434
+ When a call surprises you — slow, hangs, returns terse output, surfaces an unhelpful error — run `. /tmp/<project-name>-field-test-<ID>.sh && mcp_log <log>` to tail the server log. The pino startup banner, request handler errors, upstream API call traces, and rate-limit warnings all land in the per-server log (read via `mcp_log`) rather than coming back through `mcp_call`. Don't guess at runtime behavior from response text alone. To count upstream requests from the log, start the server with `MCP_LOG_RATE_LIMIT_THRESHOLD=0`: by default the logger drops a message repeated past its per-window threshold and reports it later as a `Suppressed N` line, so a count read mid-run comes up short.
434
435
 
435
436
  **Interpreting responses**
436
437
 
437
438
  - **`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
439
  - 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.
440
+ - **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
441
  - **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
442
  - JSON-RPC `error` only appears for protocol issues (bad session, malformed envelope, unknown method).
442
443
  - `mcp_call` already strips SSE framing. Pipe to `jq` for readability.
443
444
 
444
- ### 6. Tear down
445
+ ### 6. Render app views
446
+
447
+ Skip this step when no tool carries `_meta.ui.resourceUri`. List the app tools:
448
+
449
+ ```bash
450
+ . /tmp/<project-name>-field-test-<ID>.sh
451
+ mcp_call <url> <sid> tools/list '' <protocol> | jq -r '.result.tools[] | select(._meta.ui.resourceUri) | .name'
452
+ ```
453
+
454
+ Render each one with `mcp-ts-core app-render` against the Step 1 server. It connects as an MCP Apps client, calls the tool, loads the `ui://` view into headless `chrome-headless-shell` inside the double-iframe sandbox and CSP the MCP Apps spec prescribes, plays the host half of the protocol, and writes `report.json` plus screenshots under `--out`. No window opens.
455
+
456
+ ```bash
457
+ bunx @cyanheads/mcp-ts-core app-render --url <url> --tool <app_tool> --args '<happy-path JSON>' \
458
+ --click '<selector>' --out /tmp/<project-name>-field-test-<ID>-apps/<app_tool> > /dev/null; echo "exit=$?"
459
+ jq '{initialized, failure, toolError, errors, cspViolations, size, steps, text}' \
460
+ /tmp/<project-name>-field-test-<ID>-apps/<app_tool>/report.json
461
+ ```
462
+
463
+ The report also goes to stdout; read the file instead, since `messages` grows with every exchange. Pass one `--click` per control whose handler matters (a screenshot follows each), run a second pass with `--theme dark` when the view applies host theming, and add `--stream-input` when it renders partial input. Fill, wait-for, and evaluate steps need `renderAppTool` from `@cyanheads/mcp-ts-core/testing/apps` in a script; it returns the same report.
464
+
465
+ **A setup failure exits 1** with `app-render: <reason>` on stderr and writes no report:
466
+
467
+ | Reason names | Meaning |
468
+ |:-------------|:--------|
469
+ | A missing optional peer | `@modelcontextprotocol/client` or `@modelcontextprotocol/ext-apps` is not installed. With the user's go-ahead, `bun add -d` it; otherwise record app views as `skipped — requires <package>`. |
470
+ | No browser, or a browser path | No usable `chrome-headless-shell`. With the user's go-ahead, `npx @puppeteer/browsers install chrome-headless-shell@stable --path ~/.cache/puppeteer` (without `--path` it installs into the working directory, where the host never looks), or pass an existing build with `--browser`; otherwise record `skipped — requires chrome-headless-shell`. Never point `--browser` at a desktop browser. |
471
+ | The server is unreachable | Wrong URL, or the Step 1 server died — `mcp_log <log>`. |
472
+ | No such tool, no UI resource, an unreadable UI resource, or a `_meta.ui.csp` entry | A server finding (`bug`): the tool is unregistered, its `resourceUri` is missing, `resources/read` on the `ui://` URI fails, or a CSP domain entry is not a plain origin. |
473
+
474
+ **A view failure exits 0**: the run reached the browser, and the report says what the view did. Read every field:
475
+
476
+ - **`initialized`** — the view completed `ui/initialize`. `false`, with `failure` naming the timeout, means the view never connected: a script threw before `app.connect()` (see `errors`), the CSP blocked its SDK import (see `cspViolations`), or the HTML does not use the ext-apps `App` class. `bug`.
477
+ - **`toolError`** — the tool call failed at the protocol level, so the view received `tool-cancelled` instead of a result.
478
+ - **`errors`** — uncaught exceptions and console errors, each tagged with its `frame`. A `view` entry is the server's own UI code: `bug`. `sandbox`, `host`, and `unknown` entries come from the host pages, not the server; report them apart from the server's findings.
479
+ - **`cspViolations`** — loads the view's CSP blocked, as `directive` + `blockedURI`. An origin the view needs but its resource's `_meta.ui.csp` omits (`resourceDomains` for scripts, styles, images, fonts, and media; `connectDomains` for fetch and WebSocket; `frameDomains` for iframes) is a `bug`, because a spec-conformant host blocks the same load. `blockedURI: "eval"` means the view calls `eval` or `new Function`, which the policy never allows. An `eval` entry whose `sourceURL` points into the ext-apps bundle (`app-with-deps.js`) is Zod's guarded capability probe, which catches the refusal, not the view's own code, so it is no finding.
480
+ - **`messages`** — every message between view and host, in order, with its `direction`. A healthy run opens `view-to-host` `ui/initialize` → `ui/notifications/initialized`, then `host-to-view` `ui/notifications/tool-input` → `ui/notifications/tool-result`. A click that should reach the server shows a `view-to-host` `tools/call` and its `host-to-view` response; an `error` there is the server's rejection, or the host refusing a tool whose `_meta.ui.visibility` excludes `app`. Requests that need a user or a model (`ui/message`, `ui/open-link`, `ui/update-model-context`, `ui/request-display-mode`) are recorded and acknowledged — check their params carry what the control meant to send.
481
+ - **`steps`** — one entry per click and screenshot; `ok: false` with `No element matches <selector>` means the control is missing from the rendered view.
482
+ - **`text` and the screenshots** — the rendered text after the steps, and the PNGs (`final.png`, one per click). Open them: a blank, unstyled, clipped, or unreadable-in-dark view is a `ux` finding even when every field above is clean, and so is data in the tool result's `structuredContent` that the view never shows.
483
+ - **`size`** — the view's last `ui/notifications/size-changed`. Absent means the view never reports its size, so a host cannot fit its frame to it: `ux`.
484
+
485
+ The harness stops the browser and deletes its profile itself; the `--out` directories are removed in Step 7.
486
+
487
+ ### 7. Tear down
445
488
 
446
489
  ```bash
447
490
  . /tmp/<project-name>-field-test-<ID>.sh
448
491
  mcp_stop <pid> <log> <port>
492
+ rm -rf /tmp/<project-name>-field-test-<ID>-apps
449
493
  rm -f /tmp/<project-name>-field-test-<ID>.sh
450
494
  ```
451
495
 
452
- Kills the background server and its port-holding child, removes the server log, then removes the helper script itself. Do this *before* writing the report so nothing leaks into the next session. Pass the `port` — it's what turns "the wrapper PID is gone" into "the socket is actually free." If `mcp_stop` warns the port is still held or the PID survived SIGKILL, note it in the report and proceed — don't block on a zombie process, but do say which PID to kill.
496
+ Kills the background server and its port-holding child, removes the server log and any app-render output, then removes the helper script itself. Do this *before* writing the report so nothing leaks into the next session. Pass the `port` — it's what turns "the wrapper PID is gone" into "the socket is actually free." If `mcp_stop` warns the port is still held or the PID survived SIGKILL, note it in the report and proceed — don't block on a zombie process, but do say which PID to kill.
453
497
 
454
- ### 7. Report
498
+ ### 8. Report
455
499
 
456
500
  Four sections. Tight. The user should be able to skim the summary, scan the numbers, read details only for what matters, and act on numbered options.
457
501
 
@@ -514,5 +558,6 @@ End with:
514
558
  - [ ] **If a resource declared an `errors: [...]` contract:** ≥1 declared failure mode triggered; top-level JSON-RPC `error.code` and `error.data.reason` verified against the contract entry
515
559
  - [ ] **If any tool truncates, caps, or spills its output:** truncation forced; disclosure + a retrieval path (cursor, offset, selector, canvas handle) verified
516
560
  - [ ] External-state / auth-gated tools handled explicitly (run, skip, or confirm)
517
- - [ ] Server stopped (port confirmed free); server log and helper script removed
561
+ - [ ] **If any tool carries `_meta.ui.resourceUri`:** each view rendered with `app-render` (Step 6); `initialized`, `errors`, `cspViolations`, the message log, and the screenshots checked; a missing peer or browser recorded as skipped, not as a server finding
562
+ - [ ] Server stopped (port confirmed free); server log, app-render output, and helper script removed
518
563
  - [ ] Report: summary paragraph → size & latency table → grouped findings → numbered options
@@ -4,7 +4,7 @@ description: >
4
4
  Land working-tree changes as logical commits — the work grouped by concern, topped by a release commit (version bump, changelog, regenerated artifacts). The work commits land first, then the version bump, verification, and the release commit on top. Stops at "committed locally on main" — or, when the project releases through a release PR, at "release branch pushed, PR open". No tag, no push to main, no publish: the release-and-publish skill merges, tags, and ships from here. Distilled from the git_wrapup_instructions protocol.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.27"
7
+ version: "1.28"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -173,7 +173,7 @@ security: false # true ONLY for a security fix in this server's own source
173
173
  ---
174
174
  ```
175
175
 
176
- **Write `summary:` LAST, derived from the body you just wrote — never independently.** It is the line most readers see: `changelog:build` copies it verbatim into the `CHANGELOG.md` rollup, which ships inside the npm tarball, and in release PR mode it opens the PR body. Written from recollection rather than from the body, it reliably names a mechanism that was never built or a target that was never fixed, while the body beside it stays correct. After writing it, re-read the body and confirm every claim in the summary appears there. Derived-from-the-body means the facts come from the body — not that every body item appears: it is one headline, and comma-stitching every change into an inventory near the 350-char cap is the failure mode.
176
+ **Write `summary:` LAST, derived from the body you just wrote — never independently.** It is the line most readers see: `changelog:build` copies it verbatim into the `CHANGELOG.md` rollup, the entry file ships inside the npm tarball (`changelog/` is in `package.json` `files`; the rollup is not), and in release PR mode it opens the PR body. Written from recollection rather than from the body, it reliably names a mechanism that was never built or a target that was never fixed, while the body beside it stays correct. After writing it, re-read the body and confirm every claim in the summary appears there. Derived-from-the-body means the facts come from the body — not that every body item appears: it is one headline, and comma-stitching every change into an inventory near the 350-char cap is the failure mode.
177
177
 
178
178
  **`security:` is a source-code signal — not a dependency-CVE signal.** Set `security: true` only when this release fixes a vulnerability or adds hardening in code *this server ships*. A dependency or transitive CVE bump — even one that clears an advisory (`bun audit` going 1 → 0) — is routine maintenance: record it under `## Dependencies` with the advisory ID and leave the flag `false`. The `🛡️ Security` badge answers "does the server itself have a vuln"; a dep bump must not trip it.
179
179
 
@@ -4,7 +4,7 @@ description: >
4
4
  Pick and run a multi-phase workflow that chains foundational task skills (`git-wrapup`, `release-and-publish`, `maintenance`, `field-test`, `setup`, etc.) end-to-end. Routes user intent to a workflow file under `workflows/` — greenfield builds, maintenance + release, field-test + fix, or known-work + release. Single source for the universal rules (no commits without authorization, no destructive git, no marketing language), the orchestrator posture (own the goal, ground sub-agents in primary sources, verify against the goal), and the sub-agent strategy (orient block, parallel fanout, isolation, normalization) that apply across every workflow. Sub-agents are an optional capability — workflows run linearly when fanout isn't available.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.12"
7
+ version: "1.13"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -4,7 +4,7 @@ description: >
4
4
  Workflow: scaffold one or more new MCP server projects from `bunx @cyanheads/mcp-ts-core init` through design → build → polish → first public release. Each phase invokes a foundational skill end-to-end; this file is the sequencing and gates, not the procedural detail. Read `../SKILL.md` first for the universal rules and sub-agent strategy.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.3"
7
+ version: "1.4"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -85,7 +85,7 @@ Sub-agent runs `bunx @cyanheads/mcp-ts-core init <name>`, follows the `setup` sk
85
85
  ### Phase 2: Initial commit
86
86
  Sub-agent verifies `gh repo view --json visibility` returns `PRIVATE` (or has explicit user authorization for public) before push. Tag is `v0.1.0`.
87
87
 
88
- A private repository without GitHub Advanced Security has no code scanning, so the scaffolded `.github/workflows/codeql.yml` fails on every push until the repo is public. Keep the file. GitHub registers the workflow on the first push, and that push already runs it. Right after this push, disable it with `gh workflow disable CodeQL` and delete that failed run with `gh run delete <id>`. It stays disabled through the private checkpoints. Phase 18 turns it back on.
88
+ A private repository without GitHub Advanced Security has no code scanning, so the scaffolded `.github/workflows/codeql.yml` fails on every push until the repo is public. Keep the file. GitHub registers the workflow on the first push, and that push already runs it. Right after this push, disable it with `gh workflow disable CodeQL` and cancel that run with `gh run cancel <id>` before it fails. Keep the cancelled run: deleting a workflow's only run unregisters the workflow, and the next push registers it again as active and runs it. It stays disabled through the private checkpoints; after each checkpoint push, confirm that `gh api repos/<owner>/<repo>/actions/workflows` still shows it `disabled_manually`. Phase 18 turns it back on.
89
89
 
90
90
  ### Checkpoint commits (Phases 2, 5, 10, 16)
91
91
  Plain commits on `main`, pushed to the private repo. They follow `git-wrapup`'s step 3 conventions — grouped by concern, staged and committed by pathspec, one- or two-line bodies — and nothing else from that skill: no version bump, no changelog entry, no release branch or PR. Run end to end, `git-wrapup` bumps the version and, when the project declares a release PR mode, moves the work to `release/<version>` and opens a PR; that belongs to Phase 17 alone. Only Phase 2 tags (`v0.1.0`, annotated, `--cleanup=whitespace`).
@@ -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.22"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -95,6 +95,7 @@ Key sync points:
95
95
  - `description` matches `package.json` description
96
96
  - `environmentVariables` reflect the server config Zod schema — server-specific required vars in both entries, transport vars only in HTTP entry
97
97
  - Two package entries: one for stdio, one for HTTP (if both transports supported)
98
+ - npm entries carry `runtimeHint: "npx"` and no `packageArguments` — a registry client runs the package's bin, which ignores `run start:*` arguments; the HTTP entry fixes `MCP_TRANSPORT_TYPE` as `"value": "http"`, never as a `default`. `lint:packaging` fails the arguments and a missing or wrong transport value
98
99
 
99
100
  ### 7. GitHub Repository Metadata
100
101
 
@@ -204,7 +205,7 @@ If the project ships as an `.mcpb` bundle for Claude Desktop (check for `manifes
204
205
  **`package.json` scripts:**
205
206
 
206
207
  - `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`)
208
+ - `lint:packaging` — validates `manifest.json` ↔ `server.json` env var consistency, version parity for `manifest.json`, the plugin manifests, and the README badge, the launch shape of `server.json` npm entries (no `run start:*` arguments; a `streamable-http` entry sets `MCP_TRANSPORT_TYPE` to `"value": "http"`), 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`, `Dockerfile`, or `server.json`)
208
209
 
209
210
  **Cross-file consistency:**
210
211
 
@@ -270,7 +271,7 @@ Both must pass clean.
270
271
  - [ ] Agent protocol file accurate — no stale template content, real examples, structure matches reality
271
272
  - [ ] `.env.example` in sync with server config schema
272
273
  - [ ] `package.json` metadata complete (`description`, `mcpName`, `repository`, `author`, `keywords`, `engines`, `packageManager`)
273
- - [ ] `server.json` matches official MCP schema, versions synced, env vars current
274
+ - [ ] `server.json` matches official MCP schema, versions synced, env vars current; npm entries launch through `npx` with no arguments, and the HTTP entry fixes `MCP_TRANSPORT_TYPE` as `"value": "http"`
274
275
  - [ ] GitHub repo description matches `package.json` description; topics ↔ keywords in sync
275
276
  - [ ] `bunfig.toml` present
276
277
  - [ ] Changelog current — either monolithic `CHANGELOG.md` (hand-edited, Keep a Changelog) or directory-based (`changelog/<minor>.x/<version>.md` + rollup regenerated and in sync)
@@ -31,12 +31,8 @@ The manifest uses the official MCP schema. A typical server has two package entr
31
31
  "registryType": "npm",
32
32
  "registryBaseUrl": "https://registry.npmjs.org",
33
33
  "identifier": "@org-name/my-mcp-server",
34
- "runtimeHint": "bun",
34
+ "runtimeHint": "npx",
35
35
  "version": "1.0.0",
36
- "packageArguments": [
37
- { "type": "positional", "value": "run" },
38
- { "type": "positional", "value": "start:stdio" }
39
- ],
40
36
  "environmentVariables": [
41
37
  {
42
38
  "name": "ACME_API_KEY",
@@ -60,12 +56,8 @@ The manifest uses the official MCP schema. A typical server has two package entr
60
56
  "registryType": "npm",
61
57
  "registryBaseUrl": "https://registry.npmjs.org",
62
58
  "identifier": "@org-name/my-mcp-server",
63
- "runtimeHint": "bun",
59
+ "runtimeHint": "npx",
64
60
  "version": "1.0.0",
65
- "packageArguments": [
66
- { "type": "positional", "value": "run" },
67
- { "type": "positional", "value": "start:http" }
68
- ],
69
61
  "environmentVariables": [
70
62
  {
71
63
  "name": "ACME_API_KEY",
@@ -73,6 +65,12 @@ The manifest uses the official MCP schema. A typical server has two package entr
73
65
  "format": "string",
74
66
  "isRequired": true
75
67
  },
68
+ {
69
+ "name": "MCP_TRANSPORT_TYPE",
70
+ "description": "Selects the HTTP transport.",
71
+ "format": "string",
72
+ "value": "http"
73
+ },
76
74
  {
77
75
  "name": "MCP_HTTP_HOST",
78
76
  "description": "The hostname for the HTTP server.",
@@ -140,9 +138,9 @@ Each entry in `packages[]` describes one way to install and run the server:
140
138
  | `registryType` | Yes | `"npm"` for npm packages. |
141
139
  | `registryBaseUrl` | Yes | `"https://registry.npmjs.org"` for npm. |
142
140
  | `identifier` | Yes | The npm package name (e.g., `@org/my-server`). |
143
- | `runtimeHint` | No | `"bun"` or `"node"`. Tells clients which runtime to use. |
141
+ | `runtimeHint` | No | `"npx"` for npm packages, the registry's runner for npm. A client that runs the hint as the command launches `<runtimeHint> <identifier>@<version>`, so `"node"` or `"bun"` there names a file path that does not exist. |
144
142
  | `version` | Yes | Package version. Must match top-level `version`. |
145
- | `packageArguments` | No | Array of `{ "type": "positional", "value": "..." }` args passed after the package command. |
143
+ | `packageArguments` | No | Arguments appended to the launch command. Omit it: a framework server's bin reads none (see Package Arguments). |
146
144
  | `environmentVariables` | No | Array of env var descriptors (see below). |
147
145
  | `transport` | Yes | `{ "type": "stdio" }` or `{ "type": "streamable-http", "url": "..." }` |
148
146
 
@@ -154,28 +152,28 @@ Each entry in `packages[]` describes one way to install and run the server:
154
152
  | `description` | Yes | Human-readable purpose. |
155
153
  | `format` | No | `"string"` (default). |
156
154
  | `isRequired` | No | `true` if the server won't start without it. |
157
- | `default` | No | Default value if not set. |
155
+ | `default` | No | Value used when the user sets none. The user can change it. |
156
+ | `value` | No | Fixed value the client always sets; the user cannot change it. Use it for a setting the entry depends on, such as `MCP_TRANSPORT_TYPE` on the HTTP entry. |
158
157
 
159
158
  ### Package Arguments
160
159
 
161
- The `packageArguments` array tells clients what args to pass when running the package. For `bun run start:stdio`:
162
-
163
- ```json
164
- "packageArguments": [
165
- { "type": "positional", "value": "run" },
166
- { "type": "positional", "value": "start:stdio" }
167
- ]
168
- ```
160
+ A registry client launches an npm entry as `npx <identifier>@<version> <packageArguments>`, with the entry's `environmentVariables` set. That runs the package's bin (`dist/index.js`), not an npm script, and the bin reads no arguments: it takes its transport from `MCP_TRANSPORT_TYPE`. Leave `packageArguments` out. A `run` + `start:stdio` / `start:http` pair is npm-script syntax that reaches the bin as argv it ignores, and `lint:packaging` rejects it on any npm entry.
169
161
 
170
162
  ### Transport Patterns
171
163
 
172
- **stdio only** (one package entry):
164
+ **stdio only** (one package entry). stdio is the framework's default transport, so the entry needs no transport variable:
173
165
 
174
166
  ```json
175
167
  "transport": { "type": "stdio" }
176
168
  ```
177
169
 
178
- **stdio + HTTP** (two package entries): One entry with `start:stdio` and `{ "type": "stdio" }`, another with `start:http` and `{ "type": "streamable-http", "url": "http://localhost:{port}/mcp" }`. The HTTP entry includes additional env vars for host, port, endpoint path, and auth mode.
170
+ **stdio + HTTP** (two package entries): the stdio entry above, plus an entry with `{ "type": "streamable-http", "url": "http://localhost:{port}/mcp" }` that fixes the transport in its `environmentVariables`:
171
+
172
+ ```json
173
+ { "name": "MCP_TRANSPORT_TYPE", "description": "Selects the HTTP transport.", "format": "string", "value": "http" }
174
+ ```
175
+
176
+ Use `value`, not `default`: a default is user-editable, and without the variable the server starts on stdio. `lint:packaging` fails a `streamable-http` npm entry that does not set it to `"http"`. The HTTP entry also carries env vars for host, port, endpoint path, and auth mode.
179
177
 
180
178
  ## Generating / Updating
181
179
 
@@ -187,9 +185,9 @@ If `server.json` doesn't exist, create it from the surface area audit. If it exi
187
185
  4. Set `repository` from `package.json` repository URL, with `"source": "github"`
188
186
  5. Create package entries — one for stdio, one for HTTP (if the server supports both transports)
189
187
  6. Set `identifier` to the npm package name from `package.json`
190
- 7. Set `runtimeHint` to `"bun"`
191
- 8. Set `packageArguments` for each transport (`start:stdio`, `start:http`)
192
- 9. Populate `environmentVariables` — server-specific required vars in both entries, transport-specific vars (host, port, endpoint, auth) only in the HTTP entry, `MCP_LOG_LEVEL` in both
188
+ 7. Set `runtimeHint` to `"npx"`
189
+ 8. Leave out `packageArguments` — the bin takes its transport from `MCP_TRANSPORT_TYPE`, not from arguments
190
+ 9. Populate `environmentVariables` — server-specific required vars in both entries, `MCP_TRANSPORT_TYPE` with `"value": "http"` and the transport-specific vars (host, port, endpoint, auth) only in the HTTP entry, `MCP_LOG_LEVEL` in both
193
191
  10. All three `version` fields (top-level, and each package entry) must be identical and match `package.json`
194
192
 
195
193
  ## Keeping in Sync
@@ -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.24"
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`, runs `scripts/install-otel.ts`, then runs `scripts/prune-musl-packages.ts` (Bun cannot filter optional dependencies by libc, so it deletes the musl variants the glibc runtime image never loads) — 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.8"
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
 
@@ -84,14 +84,14 @@ A passing check is not an all-clear — it can pass while alerts stay open on th
84
84
 
85
85
  ```bash
86
86
  gh api "repos/<OWNER>/<REPO>/code-scanning/alerts?state=open&ref=refs/pull/<N>/merge" \
87
- --jq '.[] | "\(.number) \(.rule.id) \(.most_recent_instance.ref) \(.most_recent_instance.analysis_key)"'
87
+ --jq '.[] | "\(.number) \(.rule.id) \(.most_recent_instance.ref) \(.most_recent_instance.category)"'
88
88
  ```
89
89
 
90
- Every alert ends the pass in a settled state: a real finding is fixed on the branch, a genuine false positive is dismissed with a stated reason. One trap sits between those two. **An alert that the branch has already fixed but that will not close is an analysis-key problem, not a dismissal decision.** An alert raised by the retired CodeQL default setup (`analysis_key` beginning `dynamic/`) can never close itself once the repository carries its own workflow, because an alert closes only when the *same* key re-scans the ref — the new key's scan reports zero results while the old alert stays open forever. None of the three dismissal reasons (`false positive`, `won't fix`, `used in tests`) is true of a real finding that has been fixed, and `won't fix` on a high-severity alert reads to anyone auditing the repository as a decision not to fix it. Delete the retired configuration's orphaned analyses instead, newest-first:
90
+ Every alert ends the pass in a settled state: a real finding is fixed on the branch, a genuine false positive is dismissed with a stated reason. One trap sits between those two. **An alert that the branch has already fixed but that will not close is a category problem, not a dismissal decision.** GitHub re-evaluates an alert only through new analyses in the alert's own category (`most_recent_instance.category`), whichever workflow or setup uploads them. Once no current configuration uploads to that category, the fixed code is scanned only under other categories, where it reports zero results while the old alert stays open forever. The template CodeQL workflow uploads each language under `/language:<language>`, the category CodeQL default setup uses, so alerts default setup raised close normally, and default setup's earlier analyses share a set with the workflow's — neither orphaned nor deletable. The usual orphan is a retired category, such as the path-derived `.github/workflows/codeql.yml:analyze` an earlier workflow uploaded under. None of the three dismissal reasons (`false positive`, `won't fix`, `used in tests`) is true of a real finding that has been fixed, and `won't fix` on a high-severity alert reads to anyone auditing the repository as a decision not to fix it. Once the current workflow has analyzed the alert's ref under its own categories, delete the orphaned category's analyses on that ref instead, newest-first — only the most recent analysis of a set is deletable, and `confirm_delete` lets the last one go:
91
91
 
92
92
  ```bash
93
- gh api "repos/<OWNER>/<REPO>/code-scanning/analyses?per_page=100" \
94
- --jq '.[] | select(.analysis_key | startswith("dynamic/")) | "\(.id) \(.created_at) \(.ref)"'
93
+ gh api --paginate "repos/<OWNER>/<REPO>/code-scanning/analyses?ref=<REF>&per_page=100" \
94
+ --jq '.[] | select(.category == "<ORPHANED_CATEGORY>") | "\(.id) \(.created_at) \(.deletable)"'
95
95
  gh api -X DELETE "repos/<OWNER>/<REPO>/code-scanning/analyses/<ID>?confirm_delete=true"
96
96
  ```
97
97
 
@@ -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