@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
package/AGENTS.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Package:** `@cyanheads/mcp-ts-core`
4
- **Version:** 0.13.9
4
+ **Version:** 0.13.11
5
5
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
6
- **MCP SDK:** `@modelcontextprotocol/server` ^2.1.0 (protocol revisions 2026-07-28 and 2025-*)
6
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.2.0 (protocol revisions 2026-07-28 and 2025-*)
7
7
  **Zod:** ^4.6.5
8
8
  **GitHub:** [cyanheads/mcp-ts-core](https://github.com/cyanheads/mcp-ts-core)
9
9
  **npm:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core)
@@ -45,7 +45,7 @@ Both paths share the same public API. Init copies starter `package.json`, config
45
45
 
46
46
  | Subpath | Key Exports | Purpose |
47
47
  |:--------|:------------|:--------|
48
- | `@cyanheads/mcp-ts-core` | `createApp`, `tool`, `resource`, `prompt`, `appTool`, `appResource`, `APP_RESOURCE_MIME_TYPE`, `headerParam`, `Context`, `createFail`, `createRecoveryFor`, `TypedFail`, `TypedRecoveryFor`, `ReasonOf`, `HandlerContext`, `Enrich`, `EnrichHelpers`, `TypedEnrich`, `ContentCollect`, `ContentBlock`, `z`, `inputRequired`, `completable`, `isCompletable`, `CompleteCallback`, `CompleteResourceTemplateCallback`, `CacheHint`, `CacheHints`, `CacheScope`, `SessionMode`, `ResolvedSessionMode` | Main entry point |
48
+ | `@cyanheads/mcp-ts-core` | `createApp`, `tool`, `resource`, `prompt`, `appTool`, `appResource`, `APP_RESOURCE_MIME_TYPE`, `headerParam`, `Context`, `ClientCapabilities`, `createFail`, `createRecoveryFor`, `TypedFail`, `TypedRecoveryFor`, `ReasonOf`, `HandlerContext`, `Enrich`, `EnrichHelpers`, `TypedEnrich`, `ContentCollect`, `ContentBlock`, `z`, `inputRequired`, `completable`, `isCompletable`, `CompleteCallback`, `CompleteResourceTemplateCallback`, `CacheHint`, `CacheHints`, `CacheScope`, `SessionMode`, `ResolvedSessionMode` | Main entry point |
49
49
  | `/worker` | `createWorkerHandler`, `CloudflareBindings` | Cloudflare Workers entry |
50
50
  | `/tools` | `ToolDefinition`, `AnyToolDefinition`, `ToolAnnotations` | Tool definition types |
51
51
  | `/resources` | `ResourceDefinition`, `AnyResourceDefinition` | Resource definition types |
@@ -61,6 +61,7 @@ Both paths share the same public API. Init copies starter `package.json`, config
61
61
  | `/services` | `OpenRouterProvider`, `SpeechService`, `createSpeechProvider`, `ElevenLabsProvider`, `WhisperProvider`, `GraphService`, provider interfaces and types | LLM, Speech (TTS/STT), Graph services |
62
62
  | `/linter` | `validateDefinitions`, `LintReport`, `LintDiagnostic`, `LintInput`, `LintSeverity` | Definition validation |
63
63
  | `/testing` | `createMockContext`, `createMockSession`, `createFetchMock`, `runToolContract`, `createMockLogger`, `getEnrichment`, `getContentBlocks`, `createInMemoryStorage`, `expectInputRequired` | Test kit for handlers and upstream HTTP boundaries |
64
+ | `/testing/apps` | `renderAppTool`, `RenderAppToolOptions`, `AppRenderReport`, `AppRenderStep`, `AppServerTarget`, `AppHostOptions` | Headless MCP Apps host that renders an app tool's `ui://` view and reports on it (optional peers `@modelcontextprotocol/client`, `@modelcontextprotocol/ext-apps`) |
64
65
  | `/testing/fuzz` | `fuzzTool`, `fuzzResource`, `fuzzPrompt`, `zodToArbitrary`, `adversarialArbitrary`, `ADVERSARIAL_STRINGS` | Fuzz testing |
65
66
  | `/testing/vitest` | `mcpTest`, `toolContractSuite`, `McpTestFixtures` (+ re-exported `/testing` helpers) | Vitest fixtures and tool conformance suites (optional peer `vitest`) |
66
67
 
@@ -300,10 +301,11 @@ interface Context {
300
301
  readonly traceId?: string;
301
302
  readonly spanId?: string;
302
303
  readonly auth?: AuthContext;
304
+ readonly clientCapabilities: ClientCapabilities | undefined; // what the client declared; undefined when no view (2025-era stateless HTTP)
303
305
  readonly log: ContextLogger; // auto-correlated: requestId, traceId, tenantId
304
306
  readonly state: ContextState; // tenant-scoped KV storage
305
307
  readonly requestInput: RequestInputFn; // (spec, options?) => never — suspends and asks the caller for input
306
- readonly inputs: ContextInputs; // reader over a retried request's responses
308
+ readonly inputs: ContextInputs; // the request's responses, limited to what the client declared
307
309
  readonly notifyPromptListChanged?: (() => void) | undefined; // prompt list changed
308
310
  readonly notifyResourceListChanged?: (() => void) | undefined; // resource list changed
309
311
  readonly notifyResourceUpdated?: ((uri: string) => void) | undefined; // resource content changed
@@ -312,13 +314,13 @@ interface Context {
312
314
  readonly uri?: URL; // present for resource handlers
313
315
  readonly content: ContentCollect; // media blocks → prepended to content[]; never in structuredContent
314
316
  readonly enrich: Enrich; // success-path agent context → structuredContent + content[]; typed on HandlerContext<R, E>
315
- recoveryFor(reason: string): { recovery: { hint: string } } | Record<string, never>; // opt-in contract resolver
317
+ recoveryFor(reason: string): { recovery: { hint: string } } | Record<string, never>; // a declared entry's hint, in wire shape
316
318
  }
317
319
  ```
318
320
 
319
321
  ### `ctx.log`
320
322
 
321
- Opt-in domain-specific logging. Methods: `debug`, `info`, `notice`, `warning`, `error`. Auto-includes `requestId`, `traceId`, `tenantId`, `spanId`. Use `ctx.log` in handlers; global `logger` for startup/shutdown/background.
323
+ Opt-in domain-specific logging. Methods: `debug`, `info`, `notice`, `warning`, `error`. Auto-includes `requestId`, `traceId`, `tenantId`, `spanId`. Each record also reaches the client as `notifications/message` — only at or above `MCP_LOG_LEVEL` (RFC 5424 order; a client's own level can only narrow it), with sensitive fields masked as `[REDACTED]`. Use `ctx.log` in handlers; global `logger` for startup/shutdown/background.
322
324
 
323
325
  ### `ctx.state`
324
326
 
@@ -367,11 +369,33 @@ useFormat(answer.format);
367
369
  cancelled prompt is terminal, not a round to retry. `inputRequired.elicitUrl({ message, url })`
368
370
  hands the user an external link instead of a form.
369
371
 
372
+ A client can send responses on a call nothing asked for, so only what it declared reaches
373
+ `ctx.inputs`, at the mode level the request would need: an elicit result needs `elicitation` —
374
+ `elicitation.form` when it carries `content` — a sampling result `sampling` (`sampling.tools` when it
375
+ holds a tool block), a roots result `roots`, and a request with no capability view gets none.
376
+ `ctx.clientCapabilities` — the SDK-parsed `initialize` value on 2025-era connections (a bare
377
+ `elicitation: {}` reads back as `{ elicitation: { form: {} } }`), the request's envelope as sent on
378
+ 2026-07-28 — decides whether to *ask* for optional context (request roots only when `roots` is
379
+ declared, else fall through); it is never a reason to skip a consent prompt.
380
+
370
381
  A 2025-era client that declared no matching capability is refused as `client_capability_missing`,
371
382
  with a hint that ends at reconnecting. When the tool's own arguments can stand in for the answer,
372
383
  say so per call — `ctx.requestInput(spec, { fallbackHint: 'Or call again with noun supplied.' })` —
373
384
  and the sentence is appended to that hint. A consent gate passes none: it has no such field.
374
385
 
386
+ **Consent gates redeem a server record.** A capable client can still pre-answer, and any
387
+ `requestState` can be replayed within its lifetime, so a destructive handler first redeems (reads and
388
+ deletes) a `ctx.state` record `{ operation, clientId, subject, target, contentHash }` stored under a
389
+ random id — the id is all `requestState` carries — and asks again on an unknown, used, or expired id,
390
+ or when any field differs from this call. Carrying the target in `requestState` and comparing on
391
+ re-entry is replayable. Redeeming stops a sequential replay, not concurrent retries: until an atomic
392
+ `ctx.state.take` exists (#593), an action that must not repeat is made idempotent per record. The
393
+ record's storage is shared by every instance a 2026-07-28 retry can reach — `filesystem`, `supabase`,
394
+ or `cloudflare-d1`, never `cloudflare-kv`. `MCP_REQUEST_STATE_KEY` (≥ 32 bytes, the same on every
395
+ instance) opts into sealing: the framework signs the string a handler returns, the SDK rejects any
396
+ other as `-32602` `invalid_request_state` before the handler runs, and `ctx.inputs.state()` still
397
+ returns the original string. See `api-context` for the full pattern.
398
+
375
399
  ### `ctx.content`
376
400
 
377
401
  Accumulates non-text content blocks — image/audio bytes, embedded resources, resource links — onto the response: `ctx.content.image(data, mimeType)`, `ctx.content.audio(data, mimeType)`, or `ctx.content(block)` for a raw `ContentBlock`. Blocks are prepended to `content[]` after `format()` runs and never enter `structuredContent`, so a handler can emit media for the calling model without the base64 duplicating into typed output. Always present (no-op when unused); callable from handler and service layer.
@@ -382,7 +406,7 @@ See `api-context` skill for full details.
382
406
 
383
407
  ## Error Handling
384
408
 
385
- **Recommended path: declare a typed error contract.** Add `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` to `tool()` / `resource()`. Handler gets `ctx.fail(reason, msg?, data?)` typed against the reason union — typos fail at compile time. Runtime auto-populates `data.reason` for observability; linter enforces conformance against the handler body. `recovery` is required (≥5 words, lint-validated) — the single source of truth for the wire hint. Spread `ctx.recoveryFor('reason')` into `data` to opt the contract recovery onto the wire (framework mirrors `data.recovery.hint` into `content[]` text); override with explicit `{ recovery: { hint: '...' } }` when runtime context matters. Optional `severity` (`debug`/`info`/`notice`/`warning`) logs a modeled outcome below `error` — tools only, logging only: the wire envelope, the span status, and the `mcp.tool.*` metrics are untouched. Optional `thrownBy: 'service'` marks an entry the service layer produces, so `error-contract-unthrown` skips it while still checking the handler's own reasons — lint-only metadata, nothing at runtime reads it.
409
+ **Recommended path: declare a typed error contract.** Add `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` to `tool()` / `resource()`. Handler gets `ctx.fail(reason, msg?, data?)` typed against the reason union — typos fail at compile time. Runtime auto-populates `data.reason` for observability; linter enforces conformance against the handler body. `recovery` is required (≥5 words, lint-validated) — the wire hint for its reason: when a failure whose `data.reason` names the entry reaches the tool or resource factory without `data.recovery`, the framework fills `data.recovery.hint` from it — a bare `ctx.fail('reason')` and a service throw carrying `{ reason }` alike, matched on the reason alone — and mirrors it into `content[]` text. Override with explicit `{ recovery: { hint: '...' } }` when runtime context matters; a throw-site `recovery` always wins, and the thrown `McpError` itself is never changed. Optional `severity` (`debug`/`info`/`notice`/`warning`) logs a modeled outcome below `error` — tools only, logging only: the wire envelope, the span status, and the `mcp.tool.*` metrics are untouched. The framework's own `invalid_arguments` and `client_capability_missing` refusals log at `notice` unless an entry naming them declares otherwise. Optional `thrownBy: 'service'` marks an entry the service layer produces, so `error-contract-unthrown` skips it while still checking the handler's own reasons — lint-only metadata, nothing at runtime reads it.
386
410
 
387
411
  ```ts
388
412
  errors: [
@@ -394,8 +418,8 @@ errors: [
394
418
  recovery: 'Wait 30 seconds before retrying or reduce batch size.' },
395
419
  ],
396
420
  async handler(input, ctx) {
397
- // Static recovery — pulled from the contract via ctx.recoveryFor.
398
- if (queue.full()) throw ctx.fail('queue_full', undefined, { ...ctx.recoveryFor('queue_full') });
421
+ // Static recovery — the framework fills the contract's hint onto the wire.
422
+ if (queue.full()) throw ctx.fail('queue_full');
399
423
  // Dynamic recovery — interpolate runtime context, override the contract default.
400
424
  if (!matched) throw ctx.fail('no_match', `No data for ${input.pmids.length} PMIDs`, {
401
425
  pmids: input.pmids,
@@ -404,7 +428,7 @@ async handler(input, ctx) {
404
428
  }
405
429
  ```
406
430
 
407
- **`ctx.recoveryFor(reason)`** returns `{}` when no contract exists (spread-safe). Typed against the declared reason union on `HandlerContext<R>`. Works in services: `throw validationError(msg, { reason: 'X', ...ctx.recoveryFor('X') })`. Opt-in — author spreads explicitly.
431
+ **`ctx.recoveryFor(reason)`** returns the entry's hint in wire shape, `{}` when no contract exists (spread-safe). Typed against the declared reason union on `HandlerContext<R>`. Not needed to put a declared hint on the wire — the fill does that — only when the hint must ride the thrown error itself, as in a test of the handler's own throw.
408
432
 
409
433
  **Contracts are inline, per-tool.** Don't extract shared `errors[]` constants — locality is the point, and dynamic `recovery` hints need tool-specific context. Declare domain-specific failures only; **baseline codes** (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) are auto-allowed by conformance lint. The lint scans handler source only — service-layer throws still reach clients via auto-classification.
410
434
 
@@ -422,9 +446,9 @@ For HTTP responses from upstream APIs, use `httpErrorFromResponse(response, { se
422
446
 
423
447
  **Auto-classification.** Plain `Error`, `ZodError`, and any other thrown value are caught and classified automatically. Resolution order: request signal already aborted (→ `RequestCancelled`, outranking the thrown value's own code, `McpError` included) → `McpError` code (preserved as-is) → SDK `ConnectionClosed` (→ `RequestCancelled`) → engine resource-limit `RangeError` by whole message — stack overflow, maximum string size (→ `InternalError`) → JS constructor name (`SyntaxError` → `ValidationError`; `TypeError` is excluded) → provider patterns (HTTP status codes, AWS errors, DB errors) → common message patterns → `AbortError` name (→ `Timeout`) → `InternalError` fallback. A result that breaks the definition's own `output` or `enrichment` schema fails as `InternalError` naming that contract, not `ValidationError`.
424
448
 
425
- **Error-path parity.** Tool errors: `content[]` carries `Error: <message>`, then `Recovery: <hint>` when the hint says something the message does not already contain, then a closing `(reason … · not retryable)` for whichever of `data.reason` / `data.retryable` is present; the numeric code and `data.issues` stay JSON-only. `structuredContent.error` carries `{ code, message, data? }`. No `_meta.error`. Resources re-throw via JSON-RPC error envelope. An argument rejection is one of them: `-32602` with `data.issues`, plus `data.reason: 'invalid_arguments'` and a hint synthesized from the issues and the root schema — never a tool-declared `reason`, since the handler never ran. When pre-validation rewrote or dropped a key the caller wrote, `data.input` (`{ aliased: [{ alias, target }], ignored }`) names it and the hint closes with `Validated … as ….` / `Dropped undeclared key ….`; an ignore-list drop is never reported. `client_capability_missing` is the second framework-owned reason: a `ctx.requestInput` return a 2025-era connection cannot serve is refused before any wire traffic as `-32600` carrying that reason and a hint naming the capability.
449
+ **Error-path parity.** Tool errors: `content[]` carries `Error: <message>`, then `Recovery: <hint>` when the hint says something the message does not already contain, then a closing `(reason … · not retryable · request <id>)` for whichever of `data.reason` / `data.retryable` / `data.requestId` is present; the numeric code and `data.issues` stay JSON-only. `structuredContent.error` carries `{ code, message, data? }`. No `_meta.error`. Resources re-throw via JSON-RPC error envelope. An argument rejection is one of them: `-32602` with `data.issues`, plus `data.reason: 'invalid_arguments'` and a hint synthesized from the issues and the root schema — never a tool-declared `reason`, since the handler never ran. When pre-validation rewrote or dropped a key the caller wrote, `data.input` (`{ aliased: [{ alias, target }], ignored }`) names it and the hint closes with `Validated … as ….` / `Dropped undeclared key ….`; an ignore-list drop is never reported. `client_capability_missing` is the second framework-owned reason: a `ctx.requestInput` return a 2025-era connection cannot serve is refused before any wire traffic as `-32600` carrying that reason and a hint naming the capability. **`data.requestId`** is set on every error envelope the framework builds — tool results, resource reads, prompts, `httpErrorHandler`'s JSON-RPC errors — equal to the `requestId` of that call's log records: a generated token, or the client's JSON-RPC id when it is a string (`httpErrorHandler` always generates its own). A resource read refused before it is measured (auth, `params`) carries an id no record shares. It replaces a thrown `data.requestId`, is never added to the thrown `McpError`, and is left off a resource `-32602` whose `data` is exactly `{ uri }` and off `runToolContract` results.
426
450
 
427
- **Lint rules** (all warnings, surfaced in `devcheck`): `prefer-mcp-error-in-handler`, `prefer-error-factory`, `preserve-cause-on-rethrow`, `no-stringify-upstream-error`, `error-contract-conformance`, `error-contract-prefer-fail`, `error-contract-unthrown` (a declared reason no literal `ctx.fail`/`ctx.recoveryFor` in the handler names, unless marked `thrownBy: 'service'`), `error-contract-recovery-unforwarded` (a `ctx.fail` site carrying neither `ctx.recoveryFor('<reason>')` nor its own `recovery` key, so the declared hint reaches neither client surface). See `api-linter` skill.
451
+ **Lint rules** (all warnings, surfaced in `devcheck`): `prefer-mcp-error-in-handler`, `prefer-error-factory`, `preserve-cause-on-rethrow`, `no-stringify-upstream-error`, `error-contract-conformance`, `error-contract-prefer-fail`, `error-contract-unthrown` (a declared reason no literal `ctx.fail`/`ctx.recoveryFor` in the handler names, unless marked `thrownBy: 'service'`). See `api-linter` skill.
428
452
 
429
453
  See `api-errors` skill for the full pattern-matching table, error code reference, and detailed examples.
430
454
 
@@ -458,7 +482,7 @@ Managed by `@cyanheads/mcp-ts-core`. Validated via Zod. Precedence: `createApp()
458
482
  | Category | Key Variables |
459
483
  |:---------|:-------------|
460
484
  | Transport | `MCP_TRANSPORT_TYPE` (`stdio`\|`http`), `MCP_HTTP_PORT`, `MCP_HTTP_HOST`, `MCP_HTTP_ENDPOINT_PATH` |
461
- | Auth | `MCP_AUTH_MODE`, `MCP_AUTH_SECRET_KEY`, `MCP_AUTH_DISABLE_SCOPE_CHECKS`, `OAUTH_*` |
485
+ | Auth | `MCP_AUTH_MODE`, `MCP_AUTH_SECRET_KEY`, `MCP_AUTH_DISABLE_SCOPE_CHECKS`, `OAUTH_*`, `MCP_REQUEST_STATE_KEY` (opt-in `requestState` sealing) |
462
486
  | Storage | `STORAGE_PROVIDER_TYPE` (`in-memory`\|`filesystem`\|`supabase`\|`cloudflare-r2`\|`cloudflare-kv`\|`cloudflare-d1`) |
463
487
  | LLM | `OPENROUTER_API_KEY`, `OPENROUTER_APP_URL/NAME`, `LLM_DEFAULT_*` |
464
488
  | Telemetry | `OTEL_ENABLED`, `OTEL_SERVICE_NAME/VERSION`, `OTEL_EXPORTER_OTLP_*` |
@@ -485,7 +509,7 @@ describe('myTool', () => {
485
509
  });
486
510
  ```
487
511
 
488
- **`createMockContext` options:** `createMockContext()` (state included), `{ tenantId: 'test-tenant' }` (explicit tenant; defaults to `'default'`, as stdio resolves it), `{ errors: myTool.errors }` (typed `ctx.fail`), `{ inputResponses }` / `{ requestState }` (seed `ctx.inputs` to drive a multi-round-trip handler's second round directly), plus `auth`, `sessionId`, `signal`, `requestId`, `uri`, and the four `notify*` callbacks.
512
+ **`createMockContext` options:** `createMockContext()` (state included), `{ tenantId: 'test-tenant' }` (explicit tenant; defaults to `'default'`, as stdio resolves it), `{ errors: myTool.errors }` (typed `ctx.fail`), `{ inputResponses }` / `{ requestState }` (seed `ctx.inputs` to drive a multi-round-trip handler's second round directly), `{ clientCapabilities }` (seeds `ctx.clientCapabilities` and filters the seeded responses to the declared kinds, as production does — omitted, nothing is filtered), plus `auth`, `sessionId`, `signal`, `requestId`, `uri`, and the four `notify*` callbacks.
489
513
 
490
514
  **`ctx.state` in tests is the production path.** The mock backs it with a real `StorageService` over an `InMemoryProvider`, so key validation (`[a-zA-Z0-9_.\-/]+` — colons rejected), the JSON round-trip of every value, and TTL expiry behave exactly as they do in a deployment. Passing `errors` narrows the return type to `HandlerContext<ReasonOf<…>>`, which is what a definition declaring a contract types its handler's `ctx` as — so `definition.handler(input, ctx)` typechecks.
491
515
 
@@ -495,7 +519,7 @@ describe('myTool', () => {
495
519
 
496
520
  **Fixture-based tests:** `mcpTest` from `/testing/vitest` (optional peer `vitest`) extends Vitest's `test` with per-test fixtures: `ctx` (fresh mock context), `session` (session-bound context), `fetchMock` (strict fetch fake, installed/restored around the test), and `storage` (fresh in-memory `StorageService`). Override fixtures via `.extend` with the function form only — a bare value would share one mutable context across every test in the file.
497
521
 
498
- **Tool contracts:** `runToolContract(definition, input)` from `/testing` validates input/output, invokes the handler, formats content, and returns production-shaped success/error surfaces without transport auth or telemetry. `toolContractSuite(definition, { success, errors })` from `/testing/vitest` registers reusable schema/handler/error-envelope conformance cases for a server's own tool definitions.
522
+ **Tool contracts:** `runToolContract(definition, input)` from `/testing` validates input/output, invokes the handler, formats content, and returns production-shaped success/error surfaces — the declared `recovery` fill included — without transport auth, telemetry, or `data.requestId`. `toolContractSuite(definition, { success, errors })` from `/testing/vitest` registers reusable schema/handler/error-envelope conformance cases for a server's own tool definitions.
499
523
 
500
524
  **Fuzz testing:** `fuzzTool`/`fuzzResource`/`fuzzPrompt` from `/testing/fuzz` generate valid and adversarial inputs from Zod schemas via `fast-check`, then assert handler invariants (no crashes, no prototype pollution, no stack trace leaks). Returns a `FuzzReport` for custom assertions.
501
525
 
@@ -512,6 +536,8 @@ it('survives fuzz testing', async () => {
512
536
 
513
537
  Options: `numRuns` (valid inputs, default 50), `numAdversarial` (adversarial inputs, default 30), `seed` (reproducibility), `timeout` (per-call ms, default 5000), `ctx` (`MockContextOptions` for stateful handlers). Also exports `zodToArbitrary(schema)` for custom property-based tests and `ADVERSARIAL_STRINGS` for targeted injection testing.
514
538
 
539
+ **App views:** `renderAppTool` from `/testing/apps` connects to a server as an MCP Apps client, calls an app tool, and renders its `ui://` view in `chrome-headless-shell` inside the spec's double-iframe sandbox and CSP. The report carries `initialized`, `errors`, `cspViolations`, every view↔host `messages` entry, the rendered `text`, and `screenshots`; only setup failures (missing peer, no browser, server unreachable, tool missing, UI resource absent or unreadable, a `_meta.ui.csp` entry that is not a plain origin) throw. `mcp-ts-core app-render` is the CLI form. Needs the optional peers `@modelcontextprotocol/client` and `@modelcontextprotocol/ext-apps`, plus a `chrome-headless-shell` build — the newest in `~/.cache/puppeteer`, or `browserPath` / `MCP_APPS_BROWSER_PATH`; the `field-test` skill covers installing one and reading a report.
540
+
515
541
  **Vitest config:** Extend core config, add `@/` alias: `resolve: { alias: { '@/': new URL('./src/', import.meta.url).pathname } }`. Construct deps in `beforeEach`. Re-init services per suite.
516
542
 
517
543
  ---
package/CLAUDE.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Package:** `@cyanheads/mcp-ts-core`
4
- **Version:** 0.13.9
4
+ **Version:** 0.13.11
5
5
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
6
- **MCP SDK:** `@modelcontextprotocol/server` ^2.1.0 (protocol revisions 2026-07-28 and 2025-*)
6
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.2.0 (protocol revisions 2026-07-28 and 2025-*)
7
7
  **Zod:** ^4.6.5
8
8
  **GitHub:** [cyanheads/mcp-ts-core](https://github.com/cyanheads/mcp-ts-core)
9
9
  **npm:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core)
@@ -45,7 +45,7 @@ Both paths share the same public API. Init copies starter `package.json`, config
45
45
 
46
46
  | Subpath | Key Exports | Purpose |
47
47
  |:--------|:------------|:--------|
48
- | `@cyanheads/mcp-ts-core` | `createApp`, `tool`, `resource`, `prompt`, `appTool`, `appResource`, `APP_RESOURCE_MIME_TYPE`, `headerParam`, `Context`, `createFail`, `createRecoveryFor`, `TypedFail`, `TypedRecoveryFor`, `ReasonOf`, `HandlerContext`, `Enrich`, `EnrichHelpers`, `TypedEnrich`, `ContentCollect`, `ContentBlock`, `z`, `inputRequired`, `completable`, `isCompletable`, `CompleteCallback`, `CompleteResourceTemplateCallback`, `CacheHint`, `CacheHints`, `CacheScope`, `SessionMode`, `ResolvedSessionMode` | Main entry point |
48
+ | `@cyanheads/mcp-ts-core` | `createApp`, `tool`, `resource`, `prompt`, `appTool`, `appResource`, `APP_RESOURCE_MIME_TYPE`, `headerParam`, `Context`, `ClientCapabilities`, `createFail`, `createRecoveryFor`, `TypedFail`, `TypedRecoveryFor`, `ReasonOf`, `HandlerContext`, `Enrich`, `EnrichHelpers`, `TypedEnrich`, `ContentCollect`, `ContentBlock`, `z`, `inputRequired`, `completable`, `isCompletable`, `CompleteCallback`, `CompleteResourceTemplateCallback`, `CacheHint`, `CacheHints`, `CacheScope`, `SessionMode`, `ResolvedSessionMode` | Main entry point |
49
49
  | `/worker` | `createWorkerHandler`, `CloudflareBindings` | Cloudflare Workers entry |
50
50
  | `/tools` | `ToolDefinition`, `AnyToolDefinition`, `ToolAnnotations` | Tool definition types |
51
51
  | `/resources` | `ResourceDefinition`, `AnyResourceDefinition` | Resource definition types |
@@ -61,6 +61,7 @@ Both paths share the same public API. Init copies starter `package.json`, config
61
61
  | `/services` | `OpenRouterProvider`, `SpeechService`, `createSpeechProvider`, `ElevenLabsProvider`, `WhisperProvider`, `GraphService`, provider interfaces and types | LLM, Speech (TTS/STT), Graph services |
62
62
  | `/linter` | `validateDefinitions`, `LintReport`, `LintDiagnostic`, `LintInput`, `LintSeverity` | Definition validation |
63
63
  | `/testing` | `createMockContext`, `createMockSession`, `createFetchMock`, `runToolContract`, `createMockLogger`, `getEnrichment`, `getContentBlocks`, `createInMemoryStorage`, `expectInputRequired` | Test kit for handlers and upstream HTTP boundaries |
64
+ | `/testing/apps` | `renderAppTool`, `RenderAppToolOptions`, `AppRenderReport`, `AppRenderStep`, `AppServerTarget`, `AppHostOptions` | Headless MCP Apps host that renders an app tool's `ui://` view and reports on it (optional peers `@modelcontextprotocol/client`, `@modelcontextprotocol/ext-apps`) |
64
65
  | `/testing/fuzz` | `fuzzTool`, `fuzzResource`, `fuzzPrompt`, `zodToArbitrary`, `adversarialArbitrary`, `ADVERSARIAL_STRINGS` | Fuzz testing |
65
66
  | `/testing/vitest` | `mcpTest`, `toolContractSuite`, `McpTestFixtures` (+ re-exported `/testing` helpers) | Vitest fixtures and tool conformance suites (optional peer `vitest`) |
66
67
 
@@ -300,10 +301,11 @@ interface Context {
300
301
  readonly traceId?: string;
301
302
  readonly spanId?: string;
302
303
  readonly auth?: AuthContext;
304
+ readonly clientCapabilities: ClientCapabilities | undefined; // what the client declared; undefined when no view (2025-era stateless HTTP)
303
305
  readonly log: ContextLogger; // auto-correlated: requestId, traceId, tenantId
304
306
  readonly state: ContextState; // tenant-scoped KV storage
305
307
  readonly requestInput: RequestInputFn; // (spec, options?) => never — suspends and asks the caller for input
306
- readonly inputs: ContextInputs; // reader over a retried request's responses
308
+ readonly inputs: ContextInputs; // the request's responses, limited to what the client declared
307
309
  readonly notifyPromptListChanged?: (() => void) | undefined; // prompt list changed
308
310
  readonly notifyResourceListChanged?: (() => void) | undefined; // resource list changed
309
311
  readonly notifyResourceUpdated?: ((uri: string) => void) | undefined; // resource content changed
@@ -312,13 +314,13 @@ interface Context {
312
314
  readonly uri?: URL; // present for resource handlers
313
315
  readonly content: ContentCollect; // media blocks → prepended to content[]; never in structuredContent
314
316
  readonly enrich: Enrich; // success-path agent context → structuredContent + content[]; typed on HandlerContext<R, E>
315
- recoveryFor(reason: string): { recovery: { hint: string } } | Record<string, never>; // opt-in contract resolver
317
+ recoveryFor(reason: string): { recovery: { hint: string } } | Record<string, never>; // a declared entry's hint, in wire shape
316
318
  }
317
319
  ```
318
320
 
319
321
  ### `ctx.log`
320
322
 
321
- Opt-in domain-specific logging. Methods: `debug`, `info`, `notice`, `warning`, `error`. Auto-includes `requestId`, `traceId`, `tenantId`, `spanId`. Use `ctx.log` in handlers; global `logger` for startup/shutdown/background.
323
+ Opt-in domain-specific logging. Methods: `debug`, `info`, `notice`, `warning`, `error`. Auto-includes `requestId`, `traceId`, `tenantId`, `spanId`. Each record also reaches the client as `notifications/message` — only at or above `MCP_LOG_LEVEL` (RFC 5424 order; a client's own level can only narrow it), with sensitive fields masked as `[REDACTED]`. Use `ctx.log` in handlers; global `logger` for startup/shutdown/background.
322
324
 
323
325
  ### `ctx.state`
324
326
 
@@ -367,11 +369,33 @@ useFormat(answer.format);
367
369
  cancelled prompt is terminal, not a round to retry. `inputRequired.elicitUrl({ message, url })`
368
370
  hands the user an external link instead of a form.
369
371
 
372
+ A client can send responses on a call nothing asked for, so only what it declared reaches
373
+ `ctx.inputs`, at the mode level the request would need: an elicit result needs `elicitation` —
374
+ `elicitation.form` when it carries `content` — a sampling result `sampling` (`sampling.tools` when it
375
+ holds a tool block), a roots result `roots`, and a request with no capability view gets none.
376
+ `ctx.clientCapabilities` — the SDK-parsed `initialize` value on 2025-era connections (a bare
377
+ `elicitation: {}` reads back as `{ elicitation: { form: {} } }`), the request's envelope as sent on
378
+ 2026-07-28 — decides whether to *ask* for optional context (request roots only when `roots` is
379
+ declared, else fall through); it is never a reason to skip a consent prompt.
380
+
370
381
  A 2025-era client that declared no matching capability is refused as `client_capability_missing`,
371
382
  with a hint that ends at reconnecting. When the tool's own arguments can stand in for the answer,
372
383
  say so per call — `ctx.requestInput(spec, { fallbackHint: 'Or call again with noun supplied.' })` —
373
384
  and the sentence is appended to that hint. A consent gate passes none: it has no such field.
374
385
 
386
+ **Consent gates redeem a server record.** A capable client can still pre-answer, and any
387
+ `requestState` can be replayed within its lifetime, so a destructive handler first redeems (reads and
388
+ deletes) a `ctx.state` record `{ operation, clientId, subject, target, contentHash }` stored under a
389
+ random id — the id is all `requestState` carries — and asks again on an unknown, used, or expired id,
390
+ or when any field differs from this call. Carrying the target in `requestState` and comparing on
391
+ re-entry is replayable. Redeeming stops a sequential replay, not concurrent retries: until an atomic
392
+ `ctx.state.take` exists (#593), an action that must not repeat is made idempotent per record. The
393
+ record's storage is shared by every instance a 2026-07-28 retry can reach — `filesystem`, `supabase`,
394
+ or `cloudflare-d1`, never `cloudflare-kv`. `MCP_REQUEST_STATE_KEY` (≥ 32 bytes, the same on every
395
+ instance) opts into sealing: the framework signs the string a handler returns, the SDK rejects any
396
+ other as `-32602` `invalid_request_state` before the handler runs, and `ctx.inputs.state()` still
397
+ returns the original string. See `api-context` for the full pattern.
398
+
375
399
  ### `ctx.content`
376
400
 
377
401
  Accumulates non-text content blocks — image/audio bytes, embedded resources, resource links — onto the response: `ctx.content.image(data, mimeType)`, `ctx.content.audio(data, mimeType)`, or `ctx.content(block)` for a raw `ContentBlock`. Blocks are prepended to `content[]` after `format()` runs and never enter `structuredContent`, so a handler can emit media for the calling model without the base64 duplicating into typed output. Always present (no-op when unused); callable from handler and service layer.
@@ -382,7 +406,7 @@ See `api-context` skill for full details.
382
406
 
383
407
  ## Error Handling
384
408
 
385
- **Recommended path: declare a typed error contract.** Add `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` to `tool()` / `resource()`. Handler gets `ctx.fail(reason, msg?, data?)` typed against the reason union — typos fail at compile time. Runtime auto-populates `data.reason` for observability; linter enforces conformance against the handler body. `recovery` is required (≥5 words, lint-validated) — the single source of truth for the wire hint. Spread `ctx.recoveryFor('reason')` into `data` to opt the contract recovery onto the wire (framework mirrors `data.recovery.hint` into `content[]` text); override with explicit `{ recovery: { hint: '...' } }` when runtime context matters. Optional `severity` (`debug`/`info`/`notice`/`warning`) logs a modeled outcome below `error` — tools only, logging only: the wire envelope, the span status, and the `mcp.tool.*` metrics are untouched. Optional `thrownBy: 'service'` marks an entry the service layer produces, so `error-contract-unthrown` skips it while still checking the handler's own reasons — lint-only metadata, nothing at runtime reads it.
409
+ **Recommended path: declare a typed error contract.** Add `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` to `tool()` / `resource()`. Handler gets `ctx.fail(reason, msg?, data?)` typed against the reason union — typos fail at compile time. Runtime auto-populates `data.reason` for observability; linter enforces conformance against the handler body. `recovery` is required (≥5 words, lint-validated) — the wire hint for its reason: when a failure whose `data.reason` names the entry reaches the tool or resource factory without `data.recovery`, the framework fills `data.recovery.hint` from it — a bare `ctx.fail('reason')` and a service throw carrying `{ reason }` alike, matched on the reason alone — and mirrors it into `content[]` text. Override with explicit `{ recovery: { hint: '...' } }` when runtime context matters; a throw-site `recovery` always wins, and the thrown `McpError` itself is never changed. Optional `severity` (`debug`/`info`/`notice`/`warning`) logs a modeled outcome below `error` — tools only, logging only: the wire envelope, the span status, and the `mcp.tool.*` metrics are untouched. The framework's own `invalid_arguments` and `client_capability_missing` refusals log at `notice` unless an entry naming them declares otherwise. Optional `thrownBy: 'service'` marks an entry the service layer produces, so `error-contract-unthrown` skips it while still checking the handler's own reasons — lint-only metadata, nothing at runtime reads it.
386
410
 
387
411
  ```ts
388
412
  errors: [
@@ -394,8 +418,8 @@ errors: [
394
418
  recovery: 'Wait 30 seconds before retrying or reduce batch size.' },
395
419
  ],
396
420
  async handler(input, ctx) {
397
- // Static recovery — pulled from the contract via ctx.recoveryFor.
398
- if (queue.full()) throw ctx.fail('queue_full', undefined, { ...ctx.recoveryFor('queue_full') });
421
+ // Static recovery — the framework fills the contract's hint onto the wire.
422
+ if (queue.full()) throw ctx.fail('queue_full');
399
423
  // Dynamic recovery — interpolate runtime context, override the contract default.
400
424
  if (!matched) throw ctx.fail('no_match', `No data for ${input.pmids.length} PMIDs`, {
401
425
  pmids: input.pmids,
@@ -404,7 +428,7 @@ async handler(input, ctx) {
404
428
  }
405
429
  ```
406
430
 
407
- **`ctx.recoveryFor(reason)`** returns `{}` when no contract exists (spread-safe). Typed against the declared reason union on `HandlerContext<R>`. Works in services: `throw validationError(msg, { reason: 'X', ...ctx.recoveryFor('X') })`. Opt-in — author spreads explicitly.
431
+ **`ctx.recoveryFor(reason)`** returns the entry's hint in wire shape, `{}` when no contract exists (spread-safe). Typed against the declared reason union on `HandlerContext<R>`. Not needed to put a declared hint on the wire — the fill does that — only when the hint must ride the thrown error itself, as in a test of the handler's own throw.
408
432
 
409
433
  **Contracts are inline, per-tool.** Don't extract shared `errors[]` constants — locality is the point, and dynamic `recovery` hints need tool-specific context. Declare domain-specific failures only; **baseline codes** (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) are auto-allowed by conformance lint. The lint scans handler source only — service-layer throws still reach clients via auto-classification.
410
434
 
@@ -422,9 +446,9 @@ For HTTP responses from upstream APIs, use `httpErrorFromResponse(response, { se
422
446
 
423
447
  **Auto-classification.** Plain `Error`, `ZodError`, and any other thrown value are caught and classified automatically. Resolution order: request signal already aborted (→ `RequestCancelled`, outranking the thrown value's own code, `McpError` included) → `McpError` code (preserved as-is) → SDK `ConnectionClosed` (→ `RequestCancelled`) → engine resource-limit `RangeError` by whole message — stack overflow, maximum string size (→ `InternalError`) → JS constructor name (`SyntaxError` → `ValidationError`; `TypeError` is excluded) → provider patterns (HTTP status codes, AWS errors, DB errors) → common message patterns → `AbortError` name (→ `Timeout`) → `InternalError` fallback. A result that breaks the definition's own `output` or `enrichment` schema fails as `InternalError` naming that contract, not `ValidationError`.
424
448
 
425
- **Error-path parity.** Tool errors: `content[]` carries `Error: <message>`, then `Recovery: <hint>` when the hint says something the message does not already contain, then a closing `(reason … · not retryable)` for whichever of `data.reason` / `data.retryable` is present; the numeric code and `data.issues` stay JSON-only. `structuredContent.error` carries `{ code, message, data? }`. No `_meta.error`. Resources re-throw via JSON-RPC error envelope. An argument rejection is one of them: `-32602` with `data.issues`, plus `data.reason: 'invalid_arguments'` and a hint synthesized from the issues and the root schema — never a tool-declared `reason`, since the handler never ran. When pre-validation rewrote or dropped a key the caller wrote, `data.input` (`{ aliased: [{ alias, target }], ignored }`) names it and the hint closes with `Validated … as ….` / `Dropped undeclared key ….`; an ignore-list drop is never reported. `client_capability_missing` is the second framework-owned reason: a `ctx.requestInput` return a 2025-era connection cannot serve is refused before any wire traffic as `-32600` carrying that reason and a hint naming the capability.
449
+ **Error-path parity.** Tool errors: `content[]` carries `Error: <message>`, then `Recovery: <hint>` when the hint says something the message does not already contain, then a closing `(reason … · not retryable · request <id>)` for whichever of `data.reason` / `data.retryable` / `data.requestId` is present; the numeric code and `data.issues` stay JSON-only. `structuredContent.error` carries `{ code, message, data? }`. No `_meta.error`. Resources re-throw via JSON-RPC error envelope. An argument rejection is one of them: `-32602` with `data.issues`, plus `data.reason: 'invalid_arguments'` and a hint synthesized from the issues and the root schema — never a tool-declared `reason`, since the handler never ran. When pre-validation rewrote or dropped a key the caller wrote, `data.input` (`{ aliased: [{ alias, target }], ignored }`) names it and the hint closes with `Validated … as ….` / `Dropped undeclared key ….`; an ignore-list drop is never reported. `client_capability_missing` is the second framework-owned reason: a `ctx.requestInput` return a 2025-era connection cannot serve is refused before any wire traffic as `-32600` carrying that reason and a hint naming the capability. **`data.requestId`** is set on every error envelope the framework builds — tool results, resource reads, prompts, `httpErrorHandler`'s JSON-RPC errors — equal to the `requestId` of that call's log records: a generated token, or the client's JSON-RPC id when it is a string (`httpErrorHandler` always generates its own). A resource read refused before it is measured (auth, `params`) carries an id no record shares. It replaces a thrown `data.requestId`, is never added to the thrown `McpError`, and is left off a resource `-32602` whose `data` is exactly `{ uri }` and off `runToolContract` results.
426
450
 
427
- **Lint rules** (all warnings, surfaced in `devcheck`): `prefer-mcp-error-in-handler`, `prefer-error-factory`, `preserve-cause-on-rethrow`, `no-stringify-upstream-error`, `error-contract-conformance`, `error-contract-prefer-fail`, `error-contract-unthrown` (a declared reason no literal `ctx.fail`/`ctx.recoveryFor` in the handler names, unless marked `thrownBy: 'service'`), `error-contract-recovery-unforwarded` (a `ctx.fail` site carrying neither `ctx.recoveryFor('<reason>')` nor its own `recovery` key, so the declared hint reaches neither client surface). See `api-linter` skill.
451
+ **Lint rules** (all warnings, surfaced in `devcheck`): `prefer-mcp-error-in-handler`, `prefer-error-factory`, `preserve-cause-on-rethrow`, `no-stringify-upstream-error`, `error-contract-conformance`, `error-contract-prefer-fail`, `error-contract-unthrown` (a declared reason no literal `ctx.fail`/`ctx.recoveryFor` in the handler names, unless marked `thrownBy: 'service'`). See `api-linter` skill.
428
452
 
429
453
  See `api-errors` skill for the full pattern-matching table, error code reference, and detailed examples.
430
454
 
@@ -458,7 +482,7 @@ Managed by `@cyanheads/mcp-ts-core`. Validated via Zod. Precedence: `createApp()
458
482
  | Category | Key Variables |
459
483
  |:---------|:-------------|
460
484
  | Transport | `MCP_TRANSPORT_TYPE` (`stdio`\|`http`), `MCP_HTTP_PORT`, `MCP_HTTP_HOST`, `MCP_HTTP_ENDPOINT_PATH` |
461
- | Auth | `MCP_AUTH_MODE`, `MCP_AUTH_SECRET_KEY`, `MCP_AUTH_DISABLE_SCOPE_CHECKS`, `OAUTH_*` |
485
+ | Auth | `MCP_AUTH_MODE`, `MCP_AUTH_SECRET_KEY`, `MCP_AUTH_DISABLE_SCOPE_CHECKS`, `OAUTH_*`, `MCP_REQUEST_STATE_KEY` (opt-in `requestState` sealing) |
462
486
  | Storage | `STORAGE_PROVIDER_TYPE` (`in-memory`\|`filesystem`\|`supabase`\|`cloudflare-r2`\|`cloudflare-kv`\|`cloudflare-d1`) |
463
487
  | LLM | `OPENROUTER_API_KEY`, `OPENROUTER_APP_URL/NAME`, `LLM_DEFAULT_*` |
464
488
  | Telemetry | `OTEL_ENABLED`, `OTEL_SERVICE_NAME/VERSION`, `OTEL_EXPORTER_OTLP_*` |
@@ -485,7 +509,7 @@ describe('myTool', () => {
485
509
  });
486
510
  ```
487
511
 
488
- **`createMockContext` options:** `createMockContext()` (state included), `{ tenantId: 'test-tenant' }` (explicit tenant; defaults to `'default'`, as stdio resolves it), `{ errors: myTool.errors }` (typed `ctx.fail`), `{ inputResponses }` / `{ requestState }` (seed `ctx.inputs` to drive a multi-round-trip handler's second round directly), plus `auth`, `sessionId`, `signal`, `requestId`, `uri`, and the four `notify*` callbacks.
512
+ **`createMockContext` options:** `createMockContext()` (state included), `{ tenantId: 'test-tenant' }` (explicit tenant; defaults to `'default'`, as stdio resolves it), `{ errors: myTool.errors }` (typed `ctx.fail`), `{ inputResponses }` / `{ requestState }` (seed `ctx.inputs` to drive a multi-round-trip handler's second round directly), `{ clientCapabilities }` (seeds `ctx.clientCapabilities` and filters the seeded responses to the declared kinds, as production does — omitted, nothing is filtered), plus `auth`, `sessionId`, `signal`, `requestId`, `uri`, and the four `notify*` callbacks.
489
513
 
490
514
  **`ctx.state` in tests is the production path.** The mock backs it with a real `StorageService` over an `InMemoryProvider`, so key validation (`[a-zA-Z0-9_.\-/]+` — colons rejected), the JSON round-trip of every value, and TTL expiry behave exactly as they do in a deployment. Passing `errors` narrows the return type to `HandlerContext<ReasonOf<…>>`, which is what a definition declaring a contract types its handler's `ctx` as — so `definition.handler(input, ctx)` typechecks.
491
515
 
@@ -495,7 +519,7 @@ describe('myTool', () => {
495
519
 
496
520
  **Fixture-based tests:** `mcpTest` from `/testing/vitest` (optional peer `vitest`) extends Vitest's `test` with per-test fixtures: `ctx` (fresh mock context), `session` (session-bound context), `fetchMock` (strict fetch fake, installed/restored around the test), and `storage` (fresh in-memory `StorageService`). Override fixtures via `.extend` with the function form only — a bare value would share one mutable context across every test in the file.
497
521
 
498
- **Tool contracts:** `runToolContract(definition, input)` from `/testing` validates input/output, invokes the handler, formats content, and returns production-shaped success/error surfaces without transport auth or telemetry. `toolContractSuite(definition, { success, errors })` from `/testing/vitest` registers reusable schema/handler/error-envelope conformance cases for a server's own tool definitions.
522
+ **Tool contracts:** `runToolContract(definition, input)` from `/testing` validates input/output, invokes the handler, formats content, and returns production-shaped success/error surfaces — the declared `recovery` fill included — without transport auth, telemetry, or `data.requestId`. `toolContractSuite(definition, { success, errors })` from `/testing/vitest` registers reusable schema/handler/error-envelope conformance cases for a server's own tool definitions.
499
523
 
500
524
  **Fuzz testing:** `fuzzTool`/`fuzzResource`/`fuzzPrompt` from `/testing/fuzz` generate valid and adversarial inputs from Zod schemas via `fast-check`, then assert handler invariants (no crashes, no prototype pollution, no stack trace leaks). Returns a `FuzzReport` for custom assertions.
501
525
 
@@ -512,6 +536,8 @@ it('survives fuzz testing', async () => {
512
536
 
513
537
  Options: `numRuns` (valid inputs, default 50), `numAdversarial` (adversarial inputs, default 30), `seed` (reproducibility), `timeout` (per-call ms, default 5000), `ctx` (`MockContextOptions` for stateful handlers). Also exports `zodToArbitrary(schema)` for custom property-based tests and `ADVERSARIAL_STRINGS` for targeted injection testing.
514
538
 
539
+ **App views:** `renderAppTool` from `/testing/apps` connects to a server as an MCP Apps client, calls an app tool, and renders its `ui://` view in `chrome-headless-shell` inside the spec's double-iframe sandbox and CSP. The report carries `initialized`, `errors`, `cspViolations`, every view↔host `messages` entry, the rendered `text`, and `screenshots`; only setup failures (missing peer, no browser, server unreachable, tool missing, UI resource absent or unreadable, a `_meta.ui.csp` entry that is not a plain origin) throw. `mcp-ts-core app-render` is the CLI form. Needs the optional peers `@modelcontextprotocol/client` and `@modelcontextprotocol/ext-apps`, plus a `chrome-headless-shell` build — the newest in `~/.cache/puppeteer`, or `browserPath` / `MCP_APPS_BROWSER_PATH`; the `field-test` skill covers installing one and reading a report.
540
+
515
541
  **Vitest config:** Extend core config, add `@/` alias: `resolve: { alias: { '@/': new URL('./src/', import.meta.url).pathname } }`. Construct deps in `beforeEach`. Re-init services per suite.
516
542
 
517
543
  ---
package/README.md CHANGED
@@ -6,9 +6,9 @@
6
6
 
7
7
  <div align="center">
8
8
 
9
- [![Version](https://img.shields.io/badge/Version-0.13.9-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![MCP Spec](https://img.shields.io/badge/MCP%20Spec-2026--07--28-8A2BE2.svg?style=flat-square)](https://modelcontextprotocol.io/specification/2026-07-28)
9
+ [![Version](https://img.shields.io/badge/Version-0.13.11-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![MCP Spec](https://img.shields.io/badge/MCP%20Spec-2026--07--28-8A2BE2.svg?style=flat-square)](https://modelcontextprotocol.io/specification/2026-07-28)
10
10
 
11
- [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.1.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.0%2B-blueviolet.svg?style=flat-square)](https://bun.sh/)
11
+ [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.2.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.0%2B-blueviolet.svg?style=flat-square)](https://bun.sh/)
12
12
 
13
13
  [Quick start](#quick-start) · [Capabilities](#what-comes-with-it) · [API reference](#api-overview) · [Examples](#examples)
14
14
 
@@ -127,9 +127,7 @@ const search = tool('search', {
127
127
  ],
128
128
  handler: async (input, ctx) => {
129
129
  const res = await runSearch(input.query, input.limit);
130
- if (!res) {
131
- throw ctx.fail('index_unavailable', undefined, ctx.recoveryFor('index_unavailable'));
132
- }
130
+ if (!res) throw ctx.fail('index_unavailable');
133
131
  ctx.enrich({ effectiveQuery: res.parsed, totalCount: res.total });
134
132
  if (res.items.length === 0) {
135
133
  ctx.enrich({ notice: `No matches for "${input.query}". Try broader terms.` });
@@ -141,7 +139,7 @@ const search = tool('search', {
141
139
  await createApp({ tools: [search] });
142
140
  ```
143
141
 
144
- Both contracts are advertised in `tools/list`, so clients see them before calling, and the definition linter checks the handler against them. `ctx.recoveryFor()` adds the declared recovery hint to the error response.
142
+ Both contracts are advertised in `tools/list`, so clients see them before calling, and the definition linter checks the handler against them. A failure with a declared reason reaches the client carrying that entry's recovery hint, and every tool error carries the request ID its server log records share.
145
143
 
146
144
  ### Same data across client surfaces
147
145
 
@@ -236,6 +234,7 @@ Core config comes from environment variables, validated with Zod. Server-specifi
236
234
  | `MCP_HTTP_HOST` | HTTP server hostname | `127.0.0.1` |
237
235
  | `MCP_AUTH_MODE` | `none`, `jwt`, or `oauth` | `none` |
238
236
  | `MCP_AUTH_SECRET_KEY` | JWT signing secret (required for `jwt` mode) | — |
237
+ | `MCP_REQUEST_STATE_KEY` | Opt-in key (≥ 32 bytes, the same on every instance) that seals the `requestState` handlers return and rejects any a client did not get from this server | — |
239
238
  | `STORAGE_PROVIDER_TYPE` | `in-memory`, `filesystem`, `supabase`, `cloudflare-d1`/`kv`/`r2` | `in-memory` |
240
239
  | `CANVAS_PROVIDER_TYPE` | `none` or `duckdb` (optional peer dependency `@duckdb/node-api`) | `none` |
241
240
  | `OTEL_ENABLED` | Enable OpenTelemetry | `false` |
@@ -273,17 +272,18 @@ Tool and resource handlers receive a `Context`. `ctx.enrich` and `ctx.fail` are
273
272
  | `ctx.log` | `ContextLogger` | Request-scoped logger (auto-correlates requestId, traceId, tenantId); also mirrored to the client as `notifications/message` |
274
273
  | `ctx.state` | `ContextState` | Tenant-scoped key-value storage |
275
274
  | `ctx.requestInput` | `(spec) => never` | Suspend and ask the caller for more input; the handler is re-entered with the answers |
276
- | `ctx.inputs` | `ContextInputs` | Reader over a retried request's responses — `.accepted()`, `.view()`, `.state()`, `.dropped` |
275
+ | `ctx.inputs` | `ContextInputs` | The request's responses, limited to the kinds the client declared — `.accepted()`, `.view()`, `.state()`, `.dropped` |
276
+ | `ctx.clientCapabilities` | `ClientCapabilities \| undefined` | What the client declared for this request; decides whether to ask for optional context, never whether to skip a consent prompt |
277
277
  | `ctx.enrich` | `Enrich` / `TypedEnrich<E>` | Add declared result context to structured output and text content |
278
278
  | `ctx.content` | `ContentCollect` | Attach image/audio blocks to `content[]` — `content.image(data, mimeType)`, `content.audio(...)`, or a raw block |
279
279
  | `ctx.fail` | `(reason, msg?, data?) => McpError` | Creates an error for `throw ctx.fail(...)`; available with a declared `errors` contract |
280
- | `ctx.recoveryFor` | `(reason) => object` | Resolves a declared recovery hint to `{ recovery: { hint } }`, for `ctx.fail`'s data argument |
280
+ | `ctx.recoveryFor` | `(reason) => object` | Resolves a declared recovery hint to `{ recovery: { hint } }`; the framework already sends it with any failure carrying that reason and no hint of its own |
281
281
  | `ctx.signal` | `AbortSignal` | Cancellation signal |
282
282
  | `ctx.notifyResourceUpdated` | `Function?` | Notify subscribed clients a resource changed |
283
283
  | `ctx.notifyResourceListChanged` | `Function?` | Notify clients the resource list changed |
284
284
  | `ctx.notifyPromptListChanged` | `Function?` | Notify clients the prompt list changed |
285
285
  | `ctx.notifyToolListChanged` | `Function?` | Notify clients the tool list changed |
286
- | `ctx.requestId` | `string` | Unique request ID |
286
+ | `ctx.requestId` | `string` | Request ID — shared by the call's log records and returned on its errors as `data.requestId` |
287
287
  | `ctx.tenantId` | `string?` | Tenant ID (JWT `tid` claim, or `'default'` for stdio and HTTP+`MCP_AUTH_MODE=none`) |
288
288
  | `ctx.auth` | `AuthContext?` | Token claims and scopes when the request is authenticated |
289
289
  | `ctx.sessionId` | `string?` | HTTP session ID in stateful/`auto` session mode — a scoping key, not an authorization principal |
@@ -304,6 +304,7 @@ import { validateDefinitions } from '@cyanheads/mcp-ts-core/linter';
304
304
  import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
305
305
  import { mcpTest, toolContractSuite } from '@cyanheads/mcp-ts-core/testing/vitest';
306
306
  import { fuzzTool, fuzzResource, fuzzPrompt } from '@cyanheads/mcp-ts-core/testing/fuzz';
307
+ import { renderAppTool } from '@cyanheads/mcp-ts-core/testing/apps';
307
308
  ```
308
309
 
309
310
  See [CLAUDE.md/AGENTS.md](CLAUDE.md) for the complete exports reference.
@@ -334,7 +335,7 @@ const input = myTool.input.parse({ query: 'test' });
334
335
  const result = await myTool.handler(input, ctx);
335
336
  ```
336
337
 
337
- `createMockContext()` gives you a recording `log`, a `signal`, and a `state` backed by a real `StorageService` over an in-memory provider, so key validation, TTL expiry, and the JSON round-trip of stored values behave as they do in production: a `Date` reads back as its ISO string, and a value JSON cannot encode rejects. It uses tenant `'default'` unless you pass `{ tenantId }`. Pass `{ errors: myTool.errors }` for a typed `ctx.fail`, or `{ inputResponses, requestState }` to start a multi-round-trip handler at its second round.
338
+ `createMockContext()` gives you a recording `log`, a `signal`, and a `state` backed by a real `StorageService` over an in-memory provider, so key validation, TTL expiry, and the JSON round-trip of stored values behave as they do in production: a `Date` reads back as its ISO string, and a value JSON cannot encode rejects. It uses tenant `'default'` unless you pass `{ tenantId }`. Pass `{ errors: myTool.errors }` for a typed `ctx.fail`, `{ inputResponses, requestState }` to start a multi-round-trip handler at its second round, or `{ clientCapabilities }` to set what the client declared (seeded responses are then filtered to the declared kinds, as in production).
338
339
 
339
340
  `/testing` also exports `createMockSession()` for session-bound contexts, `createFetchMock()` as a strict fake for upstream HTTP, and `runToolContract()`, which runs a definition through schema, handler, formatting, and error-envelope checks. `/testing/vitest` adds the `mcpTest` fixtures (`ctx`, `session`, `fetchMock`, `storage`) and `toolContractSuite()`.
340
341
 
@@ -351,6 +352,31 @@ expect(report.prototypePollution).toBe(false);
351
352
 
352
353
  It also exports `fuzzResource`, `fuzzPrompt`, `zodToArbitrary`, and `ADVERSARIAL_STRINGS` for custom property-based tests.
353
354
 
355
+ `/testing/apps` is a headless MCP Apps host. `renderAppTool` connects to your server as an MCP Apps client, calls an app tool, loads its `ui://` view into `chrome-headless-shell` inside the sandbox and CSP the MCP Apps spec prescribes, runs scripted steps against the view, and returns a report:
356
+
357
+ ```ts
358
+ import { renderAppTool } from '@cyanheads/mcp-ts-core/testing/apps';
359
+
360
+ const run = await renderAppTool({
361
+ server: { command: 'bun', args: ['run', 'dist/index.js'] }, // or { url }
362
+ tool: 'my_app_tool',
363
+ arguments: { query: 'probe' },
364
+ steps: [{ click: '#action-btn' }, { screenshot: 'after-click' }],
365
+ });
366
+ expect(run.initialized).toBe(true);
367
+ expect(run.errors).toHaveLength(0);
368
+ expect(run.cspViolations).toHaveLength(0);
369
+ ```
370
+
371
+ The report also carries every message between the view and the host, the view's rendered text, and the screenshot paths. The same run from the command line, writing `report.json` and the screenshots under `--out`:
372
+
373
+ ```bash
374
+ bunx @cyanheads/mcp-ts-core app-render --tool my_app_tool --args '{"query":"probe"}' \
375
+ --click '#action-btn' --out ./app-run -- bun run dist/index.js
376
+ ```
377
+
378
+ It needs the optional peers `@modelcontextprotocol/client` and `@modelcontextprotocol/ext-apps`, and a `chrome-headless-shell` build: the newest one in Puppeteer's cache (`npx @puppeteer/browsers install chrome-headless-shell@stable --path ~/.cache/puppeteer`), or an explicit executable path through `browserPath`, `--browser`, or `MCP_APPS_BROWSER_PATH`. Installed Chrome, Edge, Brave, and Chromium are never searched for.
379
+
354
380
  ## Documentation
355
381
 
356
382
  - **[CLAUDE.md/AGENTS.md](CLAUDE.md)**: the framework reference, covering exports, patterns, `Context`, error codes, auth, config, and testing. It ships in the npm package, so your agent reads it from `node_modules` after `init`.
@@ -361,7 +387,7 @@ It also exports `fuzzResource`, `fuzzPrompt`, `zodToArbitrary`, and `ADVERSARIAL
361
387
 
362
388
  ```bash
363
389
  bun run rebuild # clean + build (scripts/clean.ts + scripts/build.ts)
364
- bun run devcheck # full gate: lint/format, typecheck, MCP defs, framework antipatterns, docs/skills/changelog sync, audit, outdated, secrets/TODO scan
390
+ bun run devcheck # full gate: lint/format, typecheck, MCP defs, packaging, framework antipatterns, docs/skills/changelog sync, audit, outdated, tracked-secrets and to-do marker scans
365
391
  bun run lint:mcp # validate MCP definitions against spec
366
392
  bun run test:all # rebuild + coverage + Node.js + Workers + integration
367
393
  bun run test:package # pack the tarball and consume it as an external project would
package/biome.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$schema": "https://biomejs.dev/schemas/2.5.14/schema.json",
2
+ "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json",
3
3
  "vcs": {
4
4
  "enabled": true,
5
5
  "clientKind": "git",