@cyanheads/mcp-ts-core 0.13.6 → 0.13.7

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 (131) hide show
  1. package/AGENTS.md +3 -3
  2. package/CLAUDE.md +3 -3
  3. package/README.md +55 -52
  4. package/biome.json +1 -1
  5. package/changelog/0.13.x/0.13.7.md +77 -0
  6. package/config/tsconfig.base.json +2 -2
  7. package/dist/config/index.d.ts.map +1 -1
  8. package/dist/config/index.js +42 -11
  9. package/dist/config/index.js.map +1 -1
  10. package/dist/core/app.d.ts.map +1 -1
  11. package/dist/core/app.js +21 -4
  12. package/dist/core/app.js.map +1 -1
  13. package/dist/core/context.d.ts +9 -1
  14. package/dist/core/context.d.ts.map +1 -1
  15. package/dist/core/context.js +4 -13
  16. package/dist/core/context.js.map +1 -1
  17. package/dist/core/worker.d.ts.map +1 -1
  18. package/dist/core/worker.js +7 -1
  19. package/dist/core/worker.js.map +1 -1
  20. package/dist/mcp-server/handlerContext.d.ts +6 -0
  21. package/dist/mcp-server/handlerContext.d.ts.map +1 -1
  22. package/dist/mcp-server/handlerContext.js +3 -0
  23. package/dist/mcp-server/handlerContext.js.map +1 -1
  24. package/dist/mcp-server/prompts/prompt-registration.d.ts.map +1 -1
  25. package/dist/mcp-server/prompts/prompt-registration.js +6 -3
  26. package/dist/mcp-server/prompts/prompt-registration.js.map +1 -1
  27. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  28. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +5 -1
  29. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  30. package/dist/mcp-server/transports/http/httpErrorHandler.d.ts.map +1 -1
  31. package/dist/mcp-server/transports/http/httpErrorHandler.js +15 -5
  32. package/dist/mcp-server/transports/http/httpErrorHandler.js.map +1 -1
  33. package/dist/storage/core/IStorageProvider.d.ts +5 -2
  34. package/dist/storage/core/IStorageProvider.d.ts.map +1 -1
  35. package/dist/storage/core/providerHelpers.d.ts +29 -8
  36. package/dist/storage/core/providerHelpers.d.ts.map +1 -1
  37. package/dist/storage/core/providerHelpers.js +49 -11
  38. package/dist/storage/core/providerHelpers.js.map +1 -1
  39. package/dist/storage/providers/cloudflare/d1Provider.js +4 -4
  40. package/dist/storage/providers/cloudflare/d1Provider.js.map +1 -1
  41. package/dist/storage/providers/cloudflare/kvProvider.d.ts +2 -0
  42. package/dist/storage/providers/cloudflare/kvProvider.d.ts.map +1 -1
  43. package/dist/storage/providers/cloudflare/kvProvider.js +11 -9
  44. package/dist/storage/providers/cloudflare/kvProvider.js.map +1 -1
  45. package/dist/storage/providers/cloudflare/r2Provider.d.ts.map +1 -1
  46. package/dist/storage/providers/cloudflare/r2Provider.js +8 -5
  47. package/dist/storage/providers/cloudflare/r2Provider.js.map +1 -1
  48. package/dist/storage/providers/fileSystem/fileSystemProvider.d.ts +1 -0
  49. package/dist/storage/providers/fileSystem/fileSystemProvider.d.ts.map +1 -1
  50. package/dist/storage/providers/fileSystem/fileSystemProvider.js +10 -8
  51. package/dist/storage/providers/fileSystem/fileSystemProvider.js.map +1 -1
  52. package/dist/storage/providers/inMemory/inMemoryProvider.d.ts +5 -0
  53. package/dist/storage/providers/inMemory/inMemoryProvider.d.ts.map +1 -1
  54. package/dist/storage/providers/inMemory/inMemoryProvider.js +9 -5
  55. package/dist/storage/providers/inMemory/inMemoryProvider.js.map +1 -1
  56. package/dist/storage/providers/supabase/supabaseProvider.d.ts.map +1 -1
  57. package/dist/storage/providers/supabase/supabaseProvider.js +5 -1
  58. package/dist/storage/providers/supabase/supabaseProvider.js.map +1 -1
  59. package/dist/testing/index.d.ts +6 -4
  60. package/dist/testing/index.d.ts.map +1 -1
  61. package/dist/testing/index.js +6 -4
  62. package/dist/testing/index.js.map +1 -1
  63. package/dist/utils/formatting/partialResult.d.ts +28 -2
  64. package/dist/utils/formatting/partialResult.d.ts.map +1 -1
  65. package/dist/utils/formatting/partialResult.js +46 -2
  66. package/dist/utils/formatting/partialResult.js.map +1 -1
  67. package/dist/utils/internal/error-handler/errorHandler.d.ts +8 -2
  68. package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
  69. package/dist/utils/internal/error-handler/errorHandler.js +27 -16
  70. package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
  71. package/dist/utils/internal/error-handler/mappings.d.ts +1 -0
  72. package/dist/utils/internal/error-handler/mappings.d.ts.map +1 -1
  73. package/dist/utils/internal/error-handler/mappings.js +1 -0
  74. package/dist/utils/internal/error-handler/mappings.js.map +1 -1
  75. package/dist/utils/internal/performance.d.ts +5 -1
  76. package/dist/utils/internal/performance.d.ts.map +1 -1
  77. package/dist/utils/internal/performance.js +13 -8
  78. package/dist/utils/internal/performance.js.map +1 -1
  79. package/dist/utils/security/sanitization.d.ts +15 -15
  80. package/dist/utils/security/sanitization.d.ts.map +1 -1
  81. package/dist/utils/security/sanitization.js +108 -88
  82. package/dist/utils/security/sanitization.js.map +1 -1
  83. package/dist/utils/telemetry/instrumentation.d.ts +6 -2
  84. package/dist/utils/telemetry/instrumentation.d.ts.map +1 -1
  85. package/dist/utils/telemetry/instrumentation.js +23 -8
  86. package/dist/utils/telemetry/instrumentation.js.map +1 -1
  87. package/framework-skills/add-app-tool/SKILL.md +12 -18
  88. package/framework-skills/add-prompt/SKILL.md +3 -1
  89. package/framework-skills/add-provider/SKILL.md +14 -4
  90. package/framework-skills/add-resource/SKILL.md +3 -3
  91. package/framework-skills/add-tool/SKILL.md +22 -7
  92. package/framework-skills/api-canvas/SKILL.md +2 -2
  93. package/framework-skills/api-config/SKILL.md +4 -3
  94. package/framework-skills/api-context/SKILL.md +8 -5
  95. package/framework-skills/api-errors/SKILL.md +7 -3
  96. package/framework-skills/api-linter/SKILL.md +8 -8
  97. package/framework-skills/api-telemetry/SKILL.md +9 -4
  98. package/framework-skills/api-testing/SKILL.md +21 -13
  99. package/framework-skills/api-utils/SKILL.md +3 -3
  100. package/framework-skills/api-utils/references/security.md +7 -6
  101. package/framework-skills/code-simplifier/SKILL.md +31 -18
  102. package/framework-skills/design-mcp-server/SKILL.md +62 -35
  103. package/framework-skills/git-wrapup/SKILL.md +16 -10
  104. package/framework-skills/maintenance/SKILL.md +2 -2
  105. package/framework-skills/orchestrations/SKILL.md +1 -1
  106. package/framework-skills/orchestrations/workflows/greenfield-build.md +15 -8
  107. package/framework-skills/polish-docs-meta/SKILL.md +2 -2
  108. package/framework-skills/polish-docs-meta/references/package-meta.md +1 -1
  109. package/framework-skills/polish-docs-meta/references/readme.md +3 -3
  110. package/framework-skills/release-and-publish/SKILL.md +6 -4
  111. package/framework-skills/release-pr-review/SKILL.md +18 -1
  112. package/framework-skills/report-issue-framework/SKILL.md +2 -2
  113. package/framework-skills/report-issue-local/SKILL.md +3 -3
  114. package/framework-skills/security-pass/SKILL.md +11 -3
  115. package/framework-skills/tool-defs-analysis/SKILL.md +3 -3
  116. package/package.json +15 -36
  117. package/templates/.env.example +3 -1
  118. package/templates/.github/ISSUE_TEMPLATE/feature_request.yml +1 -1
  119. package/templates/AGENTS.md +2 -2
  120. package/templates/CLAUDE.md +2 -2
  121. package/templates/Dockerfile +4 -4
  122. package/templates/package.json +3 -3
  123. package/templates/src/mcp-server/prompts/definitions/echo.prompt.ts +2 -4
  124. package/templates/src/mcp-server/resources/definitions/echo-app-ui.app-resource.ts +51 -14
  125. package/templates/src/mcp-server/resources/definitions/echo.resource.ts +1 -1
  126. package/templates/src/mcp-server/tools/definitions/echo-app.app-tool.ts +2 -3
  127. package/templates/src/mcp-server/tools/definitions/echo.tool.ts +1 -1
  128. package/dist/utils/telemetry/index.d.ts +0 -12
  129. package/dist/utils/telemetry/index.d.ts.map +0 -1
  130. package/dist/utils/telemetry/index.js +0 -12
  131. package/dist/utils/telemetry/index.js.map +0 -1
@@ -4,7 +4,7 @@ description: >
4
4
  Scaffold a new MCP tool definition. Use when the user asks to add a tool, create a new tool, or implement a new capability for the server.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.29"
7
+ version: "2.30"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -24,13 +24,13 @@ Tools use the `tool()` builder from `@cyanheads/mcp-ts-core`. Each tool lives in
24
24
 
25
25
  ## Naming
26
26
 
27
- Tools use lowercase snake_case with a canonical server/domain prefix: `{server}_{verb}_{noun}` — 3 words.
27
+ Tools use lowercase snake_case with a canonical server/domain prefix, `{server}_{verb}_{noun}` by default. Drop the noun only when the verb is a complete action on its own (`git_pull`, `git_status`): if `{server}_{verb}` leaves "…what?" unanswered — `search` what? `connect` to what? — the noun is missing. The full rule is the Name row of the `design-mcp-server` Design table.
28
28
 
29
29
  Examples: `pubmed_search_articles`, `pubmed_fetch_fulltext`, `clinicaltrials_find_eligible`.
30
30
 
31
31
  The server prefix is judged on clarity, not length: the brand name or the plain well-known word for the domain both pass (`pubmed_`, `patents_`); an abbreviation fails only when it reads as something else out of context (`loc_`, `ct_`). A fourth segment is fine when the noun is inherently two words (`openfda_search_device_clearances`). When a name resists the schema — can't pick a verb, noun feels generic, the *verb* wants a second word — that's usually a signal the scope is fuzzy; split the tool, rename, or reconsider.
32
32
 
33
- For shape selection (Workflow or Instruction variants — standard single-action tools are the default), see the `design-mcp-server` skill's Tool shapes section.
33
+ For shape selection (Workflow, Instruction, or Reference variants — standard single-action tools are the default), see the `design-mcp-server` skill's Tool shapes section.
34
34
 
35
35
  ## Template
36
36
 
@@ -243,7 +243,7 @@ const { enableWrites } = getServerConfig();
243
243
 
244
244
  // The suggestion is emitted only under the config that registers its target.
245
245
  const nextToolSuggestions = enableWrites
246
- ? [{ toolName: 'brapi_submit_observations', args: { studyDbId } }]
246
+ ? [{ toolName: 'brapi_submit_observations', reason: 'Record the observations collected for this study.', args: { studyDbId } }]
247
247
  : [];
248
248
 
249
249
  return {
@@ -344,11 +344,11 @@ The handler dispatches on the discriminator and TypeScript narrows `input` to th
344
344
 
345
345
  What reaches the wire is `{"type": "object", "oneOf": [<branch>, …]}`: branches intact, each with its own `required` list and a `const`-tagged discriminator, `additionalProperties: false` on every one. Identical bytes on a 2025-11-25 and a 2026-07-28 connection — the legacy projection inspects `outputSchema` alone and never rewrites an input root.
346
346
 
347
- Three constraints:
347
+ Four constraints:
348
348
 
349
349
  - **The union must be discriminated.** A bare `z.union(...)` is rejected: with no literal-tagged key the model has nothing to choose a branch by, and every variant's `required` would read as applying at once.
350
350
  - **`output` stays a flat `z.object`** — see the widening section below for why a non-object output root breaks the success path. When the *result* shape varies by mode, use a `kind` discriminator with presence-based optional fields and render each arm on field presence in `format()`.
351
- - **Portability is unmeasured at the parameter root.** `schema-root-oneof-portability` (strict mode only) says so; for Anthropic clients the union is the better shape, and flattening is the escape hatch if you target the widest vendor matrix.
351
+ - **Claude clients flatten the union root.** The Anthropic Messages API rejects a top-level `oneOf` in `input_schema`, so Claude clients rewrite the root before the model sees it — and the rewrite keeps only the first branch's properties, with `required: []`. A tool that must work in Claude clients takes a flat `z.object()` with an enum discriminator, optional per-mode fields, each mode's required fields named in the discriminator's `.describe()`, and the combination checked in the handler. `schema-root-oneof-portability` (strict mode only) flags the union root. Tracked in [#510](https://github.com/cyanheads/mcp-ts-core/issues/510).
352
352
  - **A union root rules out `headerParam`.** See below — the branches sit under `oneOf`, which the reachability rule excludes.
353
353
 
354
354
  ### `headerParam` mirrors an argument into a request header
@@ -539,7 +539,7 @@ async handler(input, ctx) {
539
539
 
540
540
  Single-item tools don't need this — they either succeed or throw. The partial success question only arises with array inputs.
541
541
 
542
- **Telemetry:** The framework automatically detects this pattern — when a handler result contains a non-empty `failed` array, the span gets `mcp.tool.partial_success`, `mcp.tool.batch.succeeded_count`, and `mcp.tool.batch.failed_count` attributes. No manual instrumentation needed.
542
+ **Telemetry:** The framework automatically detects this pattern — when a handler result contains a non-empty `failed` array, the span gets `mcp.tool.partial_success`, `mcp.tool.batch.succeeded_count` (from the `succeeded` array), and `mcp.tool.batch.failed_count` attributes. No manual instrumentation needed. An `output` built with `partialResultSchema()` from `/utils` is read under its own `failedKey`/`succeededKey` instead — also after `.extend()`, `.pick()`, `.omit()`, or a `.shape` spread. `.partial()` and `.required()` rebuild the fields, so a schema derived that way falls back to the literal keys.
543
543
 
544
544
  ### Empty results need context
545
545
 
@@ -811,6 +811,21 @@ async handler(input, ctx) {
811
811
 
812
812
  The same applies to optional arrays — use `?.length` guards so empty arrays are skipped, not passed through.
813
813
 
814
+ When an optional string field carries a validator (`.regex()` for a date, `.min(1)` for a cursor), a permissive schema would drop the validator and a strict one would reject the blank. Keep both by mapping the blank to `undefined` *before* the validator runs:
815
+
816
+ ```typescript
817
+ /** A blank from a form client is "unset", never a value to validate. */
818
+ const blankAsUnset = <T extends z.ZodType>(schema: T) =>
819
+ z.preprocess((value) => (value === '' ? undefined : value), schema);
820
+
821
+ input: z.object({
822
+ d1: blankAsUnset(z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()).describe('Earliest date, YYYY-MM-DD.'),
823
+ cursor: blankAsUnset(z.string().regex(/^[1-9]\d*$/).optional()).describe('Opaque continuation from the previous page.'),
824
+ }),
825
+ ```
826
+
827
+ `toJSONSchema` emits only the inner schema for a preprocess pipe in both `io` modes, so the advertised `pattern` is unchanged; `''` parses to an absent key, a real value still hits the validator, and the handler needs no extra guard.
828
+
814
829
  **Required fields are different.** If a string field is required and must be non-empty to be meaningful, `.min(1)` is correct — the client shouldn't have submitted the form without filling it in.
815
830
 
816
831
  ### Match response density to context budget
@@ -4,7 +4,7 @@ description: >
4
4
  DataCanvas primitive reference — a Tier 3 SQL/analytical workspace for tabular MCP servers, backed by DuckDB. Use when registering tables from upstream APIs, running ad-hoc SQL across them, and exporting results. Covers the acquire → register → query → export flow, per-table TTL, the token-sharing pattern for multi-agent collaboration, env config, and Cloudflare Workers fail-closed behavior.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.3"
7
+ version: "2.4"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -511,7 +511,7 @@ The merged iterable streams — the helper does not double-buffer the full sourc
511
511
  | Sync or async | Caller-supplied | Forwarded to `registerTable` as-is |
512
512
  | Sync or async | Omitted | Helper infers via `inferSchemaFromRows` over preview buffer + sentinel |
513
513
 
514
- When the preview budget is small (single-digit rows) and the sniff window matters, pass `schema` explicitly — the helper's window is only as large as the preview budget allows.
514
+ Pass `schema` explicitly whenever a column's type can't be read off the first rows — a fractional column whose leading values are all `0` or `null` sniffs as `BIGINT` (or `VARCHAR`), and the appender then truncates or stringifies every later value without an error. The sniff window is only as large as the preview budget, so shrinking `previewChars` widens the exposure; the same applies to `registerTable` called without a schema. Derive the schema from the row type once and pass it to both calls.
515
515
 
516
516
  ### Cancellation and partial state
517
517
 
@@ -4,7 +4,7 @@ description: >
4
4
  Reference for core and server configuration in `@cyanheads/mcp-ts-core`. Covers env var tables with defaults, priority order, server-specific Zod schema pattern, and Workers lazy-parsing requirement.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.19"
7
+ version: "1.20"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -209,8 +209,9 @@ Activated when `SUPABASE_URL` is set.
209
209
  | `OTEL_ENABLED` | `openTelemetry.enabled` | `false` | Enable OpenTelemetry export |
210
210
  | `OTEL_SERVICE_NAME` | `openTelemetry.serviceName` | `createApp` `name` → `package.json` `name` | Seeded from `createApp({ name })` when unset; an env value wins |
211
211
  | `OTEL_SERVICE_VERSION` | `openTelemetry.serviceVersion` | `package.json` `version` | |
212
- | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `openTelemetry.tracesEndpoint` | — | OTLP traces endpoint URL |
213
- | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | `openTelemetry.metricsEndpoint` | — | OTLP metrics endpoint URL |
212
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | — | — | OTLP/HTTP base URL; resolves `tracesEndpoint` to `<base>/v1/traces` and `metricsEndpoint` to `<base>/v1/metrics` (path prefix kept) when the signal-specific variable is unset |
213
+ | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `openTelemetry.tracesEndpoint` | — | OTLP traces endpoint URL; overrides the base, used as-is |
214
+ | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | `openTelemetry.metricsEndpoint` | — | OTLP metrics endpoint URL; overrides the base, used as-is |
214
215
  | `OTEL_TRACES_SAMPLER_ARG` | `openTelemetry.samplingRatio` | `1.0` | 0–1; fraction of traces to export |
215
216
  | `OTEL_LOG_LEVEL` | `openTelemetry.logLevel` | `INFO` | OTel SDK internal log level: `NONE` \| `ERROR` \| `WARN` \| `INFO` \| `DEBUG` \| `VERBOSE` \| `ALL` |
216
217
 
@@ -4,7 +4,7 @@ description: >
4
4
  Canonical reference for the unified `Context` object passed to every tool and resource handler in `@cyanheads/mcp-ts-core`. Covers the full interface, its `RequestContext` base, all sub-APIs (`ctx.log`, `ctx.state`, `ctx.requestInput`, `ctx.inputs`, `ctx.enrich`, `ctx.content`), and when to use each.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.5"
7
+ version: "2.6"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -210,7 +210,7 @@ interface ContextState {
210
210
  ### Usage
211
211
 
212
212
  ```ts
213
- // Store — accepts any serializable value, no manual JSON.stringify needed
213
+ // Store — accepts any JSON-serializable value, no manual JSON.stringify needed
214
214
  await ctx.state.set('item/123', { name: 'Widget', count: 42 });
215
215
  await ctx.state.set('session/xyz', token, { ttl: 3600 }); // TTL in seconds
216
216
 
@@ -236,6 +236,7 @@ if (page.cursor) { /* more pages available */ }
236
236
 
237
237
  - Throws `McpError(InvalidRequest)` if `tenantId` is missing. Won't happen in stdio (any auth mode) or HTTP+`MCP_AUTH_MODE=none` — both default to `'default'`. Can happen in HTTP+`MCP_AUTH_MODE=jwt`/`oauth` when the token lacks a `tid` claim (intentional fail-closed: distinct authenticated callers must not silently share state).
238
238
  - Keys are tenant-prefixed internally; handlers never need to namespace manually.
239
+ - **Values round-trip as JSON** on every provider, `in-memory` included: reads return the JSON form, so a `Date` comes back as its ISO string, a `Map` as `{}`, and a returned object never shares identity with the one written. Validate reads with a schema that matches the stored form (`z.string()` for a date, not `z.date()`). A `bigint`, a cyclic reference, or a top-level `undefined`, function, or symbol throws `McpError(SerializationError)` before anything is written; in `setMany`, one such value rejects the whole batch.
239
240
  - **Key charset:** `^[a-zA-Z0-9_.\-/]+$`, 1024 chars max, no `..`. Slashes are the namespace separator — a colon (`item:123`) throws `McpError(ValidationError)` on every call. The rule covers `list` prefixes and every key in a batch operation. `createMockContext().state` enforces it identically, so an illegal key fails in the test rather than in a deployment.
240
241
  - **Workers persistence:** The `in-memory` provider loses data on cold starts. Use `cloudflare-kv`, `cloudflare-r2`, or `cloudflare-d1` for durable storage in Workers.
241
242
 
@@ -712,9 +713,11 @@ For tools that cap a list (i.e. have a `limit`/`per_page`/`page_size`/`max_resul
712
713
 
713
714
  ```ts
714
715
  enrichment: {
715
- truncated: z.boolean().describe('True when the list was capped.'),
716
- shown: z.number().describe('Number of items returned.'),
717
- cap: z.number().describe('The limit that was applied.'),
716
+ // Optional: truncated() writes these only when the cap is hit, and a required
717
+ // enrichment field left unset fails the effective-output parse on every complete result.
718
+ truncated: z.boolean().optional().describe('True when the list was capped.'),
719
+ shown: z.number().optional().describe('Number of items returned.'),
720
+ cap: z.number().optional().describe('The limit that was applied.'),
718
721
  truncationCeiling: z.number().optional().describe('Upper bound for omitted items (threshold bound).'),
719
722
  },
720
723
  async handler(input, ctx) {
@@ -4,7 +4,7 @@ description: >
4
4
  McpError constructor, JsonRpcErrorCode reference, and error handling patterns for `@cyanheads/mcp-ts-core`. Use when looking up error codes, understanding where errors should be thrown vs. caught, or using ErrorHandler.tryCatch in services.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.15"
7
+ version: "1.16"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -205,6 +205,8 @@ throw validationError(message, {
205
205
 
206
206
  Throw when the server has authoritative classification — auth failure, rate limit, schema violation, upstream 5xx, missing required input. Don't throw when "this looks wrong" depends on intent the server can't see. For mutators, surface raw pre- and post-mutation observable state in the response and let the agent decide whether it matches intent — the server can detect that the file shrunk, but only the agent knows whether it was supposed to. Tell: defensive code justified as a free rider on other work — audit it standalone, and it usually doesn't earn its keep.
207
207
 
208
+ A best-effort call that catches and degrades must still rethrow on `ctx.signal?.aborted`: `catch (err) { if (ctx.signal?.aborted) throw err; return degraded(); }`. One example is an enrichment lookup whose failure should return the primary result with a notice. The factory maps a cancelled handler to `RequestCancelled` only when the handler throws. A catch-all degrade turns the caller's cancellation into a "successful" response and logs a false failure warning.
209
+
208
210
  ---
209
211
 
210
212
  ## Error Factories (fallback)
@@ -312,7 +314,7 @@ Use factories or `McpError` directly when the code must be exact — auto-classi
312
314
 
313
315
  The framework applies these steps in order — first match wins:
314
316
 
315
- 1. **Request signal aborted** — `ctx.signal.aborted` is `true` when the handler unwinds → `RequestCancelled`. Resolved by the tool and resource handler factories before the thrown value is classified at all, so it outranks every step below, `McpError` included: the caller withdrew the request, and what the handler threw on the way out does not change that. Covers every shape an abort leaves behind — a `notifications/cancelled` `reason` string, the `DOMException` named `AbortError` a reason-less cancellation produces, a service's own `McpError`, and the SDK's `SdkError(ConnectionClosed)` on transport close. The accepted cost is that an unrelated fault raised after the abort is recorded as a cancellation too; it is bounded, because the SDK writes no response for a request whose signal it aborted. A handler that throws while the signal is live is untouched by this step.
317
+ 1. **Request signal aborted** — `ctx.signal.aborted` is `true` when the handler unwinds → `RequestCancelled`. Resolved before the thrown value is classified at all — by the tool and resource handler factories, and by the HTTP transport's error handler against the inbound request's signal, which catches a caller that hangs up before any handler runs (mid-body, say) and answers it 499 — so it outranks every step below, `McpError` included: the caller withdrew the request, and what the handler threw on the way out does not change that. Covers every shape an abort leaves behind — a `notifications/cancelled` `reason` string, the `DOMException` named `AbortError` a reason-less cancellation produces, a service's own `McpError`, and the SDK's `SdkError(ConnectionClosed)` on transport close. The accepted cost is that an unrelated fault raised after the abort is recorded as a cancellation too; it is bounded, because the SDK writes no response for a request whose signal it aborted. A handler that throws while the signal is live is untouched by this step.
316
318
  2. **`McpError` instance** — `error.code` is preserved as-is; no classification needed.
317
319
  3. **SDK transport-closed rejection** — an `SdkError` carrying `SdkErrorCode.ConnectionClosed` → `RequestCancelled`. The SDK rejects every in-flight request when the transport closes, which is what a client disconnect looks like from inside a handler. Matched on the code, not the message: one of its wordings says "aborted" and would otherwise be caught by the generic abort pattern in step 6 and read as a `Timeout`. Still the rule for a throw raised where no request signal is in scope — a service, an outbound leg, a background task.
318
320
  4. **JS constructor name** — matched against a fixed table (e.g. `ZodError` → `ValidationError`, `SyntaxError` → `ValidationError`). Note: `TypeError` is intentionally excluded — runtime TypeErrors are programmer errors, not validation failures.
@@ -462,12 +464,14 @@ const parsed = await ErrorHandler.tryCatch(
462
464
 
463
465
  `tryCatch` always logs and rethrows — it never swallows errors. The `fn` argument may be synchronous or return a `Promise`; both are handled via `Promise.resolve(fn())`.
464
466
 
467
+ **The thrown error's `data` is wire-visible.** A handler that lets it propagate forwards it as `structuredContent.error.data` (tools) or JSON-RPC `error.data` (resources, prompts). It carries `originalErrorName`, `originalMessage`, `rootCause` (`{ name, message }`), and the canonical fields and `extra` of `context`, but never a stack: `originalStack` and the full `causeChain` go to the log record only.
468
+
465
469
  **Options** (`Omit<ErrorHandlerOptions, 'rethrow'>`):
466
470
 
467
471
  | Option | Type | Required | Purpose |
468
472
  |:-------|:-----|:--------:|:--------|
469
473
  | `operation` | `string` | Yes | Name logged with the error |
470
- | `context` | `ErrorContext` | No | Extra structured fields merged into the log record; `requestId` and `timestamp` receive special treatment |
474
+ | `context` | `ErrorContext` | No | Structured fields merged into the log record and the thrown error's client-visible `data`; `requestId` and `timestamp` receive special treatment |
471
475
  | `errorCode` | `JsonRpcErrorCode` | No | Code used if the caught error is not already an `McpError` |
472
476
  | `input` | `unknown` | No | Input value sanitized and logged alongside the error |
473
477
  | `critical` | `boolean` | No | Marks the error as critical in logs (default `false`) |
@@ -4,7 +4,7 @@ description: >
4
4
  MCP definition linter rules reference. Use when `bun run lint:mcp` or `bun run devcheck` reports a lint error or warning (`format-parity`, `schema-is-object`, `name-format`, `server-json-*`, etc.) and you need to understand the rule, its severity, and how to fix it. Every rule ID the linter emits has an entry in this doc.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.17"
7
+ version: "1.18"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -85,7 +85,7 @@ Why this family exists: different MCP clients forward different surfaces of a to
85
85
 
86
86
  Two consequences worth knowing when writing a `format()`:
87
87
 
88
- - **The string sentinel is alphanumeric so escaping does not break it.** `content[]` is markdown carrying upstream text you do not control, so escaping `_`, `*`, `` ` ``, `[`, `<` at the render boundary is correct — and it leaves an alphanumeric probe byte-identical. Markdown escaping, HTML escaping, and URL encoding all pass. You never need to carve an exception into your escape set to keep `lint:mcp` green.
88
+ - **The string sentinel is alphanumeric so escaping does not break it.** `content[]` is markdown carrying upstream text you do not control, so escaping at the render boundary is correct — and it leaves an alphanumeric probe byte-identical. Escape a character only where it would change rendering, per CommonMark/GFM rules: intraword `_` (`snake_case`), a `<` that cannot open a tag (`p<0.05`), and a `[` that cannot form a link all stay raw. Agents read `content[]` as text and copy spans out of it, so a blanket escape set turns into backslash noise in their output. Markdown escaping, HTML escaping, and URL encoding all pass. You never need to carve an exception into your escape set to keep `lint:mcp` green.
89
89
  - **Schema-dictated values must render as their own token.** A required `kind: z.enum(['full', 'outline'])` that `format()` never renders is not satisfied by the letters `full` appearing inside a longer word elsewhere in the output — `case_name_full`, `inactive`, `listing`. Render the field, or render its key name as a label.
90
90
 
91
91
  ### format-parity
@@ -373,9 +373,9 @@ Fires when emitted output contains `$defs` or `$ref`. Gemini rejects these (`400
373
373
 
374
374
  **Severity:** warning (only when `portability: 'strict'`)
375
375
 
376
- Fires when a tool's advertised `inputSchema` has a root-level `oneOf` — that is, when `input` is a `z.discriminatedUnion(...)`. The emitted shape is valid 2020-12, every branch is a typed object, and the bytes are identical on both MCP protocol revisions. What is unmeasured is vendor handling of a `oneOf` at the *parameter* root: a client that reads only `type` and `properties` would see a parameterless tool and drop the constraint silently rather than erroring. Opt-in, because for Anthropic clients the union is the better shape.
376
+ Fires when a tool's advertised `inputSchema` has a root-level `oneOf` — that is, when `input` is a `z.discriminatedUnion(...)`. The emitted shape is valid 2020-12, every branch is a typed object, and the bytes are identical on both MCP protocol revisions. Vendor handling of a `oneOf` at the *parameter* root varies: a client that reads only `type` and `properties` sees a parameterless tool and drops the constraint silently rather than erroring, and Claude clients — the Anthropic Messages API rejects a top-level `oneOf` — rewrite the root to its first branch's properties, hiding every other mode from the model. Opt-in for now; whether it warns by default is tracked in [#510](https://github.com/cyanheads/mcp-ts-core/issues/510).
377
377
 
378
- **Fix (only if you need the widest vendor reach):** flatten to a single `z.object()` with a discriminator field and optional per-mode fields, and validate the combination in the handler.
378
+ **Fix (for any tool that must work in Claude clients):** flatten to a single `z.object()` with a discriminator field and optional per-mode fields, and validate the combination in the handler.
379
379
 
380
380
  ### schema-dialect-tag
381
381
 
@@ -1065,11 +1065,11 @@ Singularization covers only the bounded suffixes above (`ies` → `y`, `ses`/`xe
1065
1065
  A silently capped list leaves the agent unaware that results were cut off — it may treat a partial set as complete. Use `ctx.enrich.truncated({ shown, cap })` for the one-liner:
1066
1066
 
1067
1067
  ```ts
1068
- // In the enrichment block:
1068
+ // In the enrichment block — optional, since truncated() fires only on a capped page:
1069
1069
  enrichment: {
1070
- truncated: z.boolean().describe('True when the list was capped at the limit.'),
1071
- shown: z.number().describe('Number of items returned.'),
1072
- cap: z.number().describe('The limit applied.'),
1070
+ truncated: z.boolean().optional().describe('True when the list was capped at the limit.'),
1071
+ shown: z.number().optional().describe('Number of items returned.'),
1072
+ cap: z.number().optional().describe('The limit applied.'),
1073
1073
  },
1074
1074
 
1075
1075
  // In the handler:
@@ -4,7 +4,7 @@ description: >
4
4
  Catalog of OpenTelemetry instrumentation built into framework `@cyanheads/mcp-ts-core` — spans, metrics, completion logs, env config, runtime caveats, custom instrumentation patterns, and cardinality rules. Use when enabling OTel export, adding custom spans or metrics in services, debugging missing telemetry, looking up attribute names, or deciding what's safe to put on a metric attribute vs. a span.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.12"
7
+ version: "1.13"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -28,8 +28,9 @@ OTel is **off by default**. `OTEL_ENABLED=true` alone does nothing — you also
28
28
  | Env var | Default | Purpose |
29
29
  |:--------|:--------|:--------|
30
30
  | `OTEL_ENABLED` | `false` | Master switch. Must be `true` to start the SDK. |
31
- | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | — | OTLP/HTTP traces endpoint (e.g. `http://localhost:4318/v1/traces`). |
32
- | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | — | OTLP/HTTP metrics endpoint (e.g. `http://localhost:4318/v1/metrics`). |
31
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | — | OTLP/HTTP base URL (e.g. `http://localhost:4318`). Traces go to `<base>/v1/traces`, metrics to `<base>/v1/metrics`; a path prefix is kept. |
32
+ | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | — | OTLP/HTTP traces endpoint (e.g. `http://localhost:4318/v1/traces`). Overrides the base for traces; used as-is. |
33
+ | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | — | OTLP/HTTP metrics endpoint (e.g. `http://localhost:4318/v1/metrics`). Overrides the base for metrics; used as-is. |
33
34
  | `OTEL_SERVICE_NAME` | `createApp` `name` → `package.json` `name` | `service.name` resource attribute. Seeded from `createApp({ name })` when unset; an env value wins. |
34
35
  | `OTEL_SERVICE_VERSION` | `package.json` `version` | `service.version` resource attribute. |
35
36
  | `OTEL_TRACES_SAMPLER_ARG` | `1.0` | Trace sampling ratio (0–1) for `TraceIdRatioBasedSampler`. |
@@ -37,6 +38,8 @@ OTel is **off by default**. `OTEL_ENABLED=true` alone does nothing — you also
37
38
 
38
39
  Metrics push via `PeriodicExportingMetricReader` every **15 seconds**. Traces use `BatchSpanProcessor`.
39
40
 
41
+ Endpoints resolve per the [OTLP exporter spec](https://opentelemetry.io/docs/specs/otel/protocol/exporter/#endpoint-urls-for-otlphttp): the signal-specific variable as-is, else the base plus the signal path. Those two exporters are the only export path. A signal with no resolved endpoint exports nothing, OTel log records are never exported, and `NodeSDK`'s own `OTEL_METRICS_EXPORTER` / `OTEL_LOGS_EXPORTER` defaults are not consulted.
42
+
40
43
  ---
41
44
 
42
45
  ## Runtime support
@@ -61,6 +64,8 @@ Cloud platform detection auto-populates resource attributes:
61
64
 
62
65
  Spans batch and metrics push on a 15-second cycle, so a process that exits between cycles takes its telemetry with it. `ServerHandle.shutdown()` is the drain: it stops the transport, runs the `teardown` hook, then force-flushes traces and metrics through the OTLP exporters and closes the logger.
63
66
 
67
+ A failed flush is logged as a warning and the logger still closes, so the final log lines survive. The usual cause is an exporter that can't reach its collector and hits the 5 s OTel shutdown ceiling.
68
+
64
69
  | Trigger | Path | Exit |
65
70
  |:--------|:-----|:-----|
66
71
  | `SIGTERM` / `SIGINT` | `shutdown(signal)`, then an explicit exit | `0`, or `1` when the backstop fires |
@@ -107,7 +112,7 @@ Two consequences worth knowing when reading a dashboard:
107
112
  | `mcp.tool.duration` / `mcp.resource.duration` | The handler **plus** validation, formatting, and the enrichment merge — time to produce the result, not time spent in handler code. An expensive `format()` shows up here. |
108
113
  | `mcp.tool.output_bytes` / `mcp.resource.output_bytes` | The handler's returned domain value, not the assembled result. `content[]` re-renders the data the structured payload already carries, so measuring the assembly would double-count it. Nothing is recorded for a call that fails after the handler. |
109
114
 
110
- `mcp.tool.partial_success` and the `mcp.tool.batch.*` counts read the same domain value, so a batch envelope (`{ succeeded, failed }`) is still detected once the result has been assembled around it.
115
+ `mcp.tool.partial_success` and the `mcp.tool.batch.*` counts read the same domain value, so a batch envelope (`{ succeeded, failed }`) is still detected once the result has been assembled around it. For an `output` built with `partialResultSchema()`, the arrays are read under its `failedKey`/`succeededKey`, resolved once per definition from the output schema.
111
116
 
112
117
  Trace context propagates across boundaries via W3C `traceparent` headers. See `api-utils` → `telemetry/trace` for `withSpan`, `buildTraceparent`, `extractTraceparent`, `createContextWithParentTrace`, `injectCurrentContextInto`, `runInContext` signatures.
113
118
 
@@ -4,7 +4,7 @@ description: >
4
4
  Testing patterns for MCP tool/resource handlers using `createMockContext` and Vitest. Covers mock context options, handler testing, McpError assertions, format testing, Vitest config setup, and test isolation conventions.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.10"
7
+ version: "1.11"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -13,7 +13,7 @@ metadata:
13
13
 
14
14
  Tests target handler behavior directly — call `handler(input, ctx)`, assert on the return value or thrown error. The framework's handler factory (try/catch, formatting, telemetry) is not involved. Use `createMockContext` from `@cyanheads/mcp-ts-core/testing` to construct the `ctx` argument.
15
15
 
16
- **Additional exports from `/testing`:** `createMockSession()` binds a mock handler context to an HTTP session; `createFetchMock()` provides a strict upstream HTTP fake; `runToolContract()` executes a definition through schema, handler, formatting, enrichment/content, and production-shaped error-envelope checks. `createMockLogger()` returns a standalone `MockContextLogger`, and `createInMemoryStorage(options?)` provides a real `StorageService` backed by `InMemoryProvider`.
16
+ **Additional exports from `/testing`:** `createMockSession()` binds a mock handler context to an HTTP session; `createFetchMock()` provides a strict upstream HTTP fake; `runToolContract()` executes a definition through schema, handler, formatting, enrichment/content, and production-shaped error-envelope checks. `createMockLogger()` returns a standalone `MockContextLogger`, `createInMemoryStorage(options?)` provides a real `StorageService` backed by `InMemoryProvider`, and `expectInputRequired(run)` returns the `input_required` result a multi-round-trip handler asked for (see [Mock inputs](#mock-inputs)).
17
17
 
18
18
  **Philosophy:** Test behavior, not implementation. Refactors should not break tests. Match the repo's existing test layout: fresh scaffolds use `tests/`, while colocated `src/**/*.test.ts` files are also supported. Integration tests at I/O boundaries over unit tests of internals.
19
19
 
@@ -101,6 +101,15 @@ try {
101
101
 
102
102
  Routes match in registration order. `match` accepts an exact URL, `RegExp`, or request predicate; `respond` accepts a clonable `Response` or response factory. Set `once: true` for one-shot behavior. Unmatched requests throw unless `onUnhandled` is provided.
103
103
 
104
+ **A request predicate routes on the URL's origin, never a prefix.** `req.url.startsWith(BASE_URL)` also matches a lookalike host (`https://api.example.test.evil.com/...`), which CodeQL reports as high-severity incomplete URL substring sanitization — it scans test files as readily as `src/`, so a suite that is green locally still fails the security check on a pull request. Parse the URL and compare origins, matching the path separately:
105
+
106
+ ```ts
107
+ match: (req) => {
108
+ const url = new URL(req.url);
109
+ return url.origin === new URL(BASE_URL).origin && url.pathname.startsWith('/items/');
110
+ },
111
+ ```
112
+
104
113
  ---
105
114
 
106
115
  ## Tool conformance with `toolContractSuite`
@@ -188,6 +197,7 @@ interface MockContextOptions<TErrors extends readonly ErrorContract[] | undefine
188
197
  `ctx.state` is a real `StorageService` over an `InMemoryProvider` — the production storage path, not a `Map`. A test therefore sees the same rules a deployed server enforces:
189
198
 
190
199
  - **Keys** match `^[a-zA-Z0-9_.\-/]+$` and may not contain `..`. Colons are rejected, so `cache:v1:abc` throws `McpError(ValidationError)` in the test exactly as it would in a deployment; use `cache/v1/abc`.
200
+ - **Values** round-trip as JSON, as on every persistent provider. A read returns a fresh object in its JSON form — a `Date` reads back as its ISO string — so a test cannot pass on identity or on a `Date`/`Map` surviving storage. A value JSON cannot encode (`bigint`, a cyclic reference, a top-level `undefined`, function, or symbol) rejects with `McpError(SerializationError)`.
191
201
  - **TTL** is honored. An entry written with `{ ttl: 30 }` reads back as `null` once 30 seconds elapse — drive the clock with `vi.useFakeTimers()` to assert expiry.
192
202
  - **`getMany` / `setMany` / `deleteMany` / `list`** validate every key and prefix, and `list` paginates with the same opaque cursors.
193
203
  - **Cancellation** applies: once `ctx.signal` aborts, state operations reject.
@@ -198,6 +208,9 @@ const ctx = createMockContext();
198
208
  await ctx.state.set('cache/v1/abc', { hits: 1 }, { ttl: 30 });
199
209
  await expect(ctx.state.get('cache/v1/abc')).resolves.toEqual({ hits: 1 });
200
210
  await expect(ctx.state.set('cache:v1:abc', {})).rejects.toThrow(McpError);
211
+
212
+ await ctx.state.set('seen/abc', { at: new Date('2026-01-01T00:00:00Z') });
213
+ await expect(ctx.state.get('seen/abc')).resolves.toEqual({ at: '2026-01-01T00:00:00.000Z' });
201
214
  ```
202
215
 
203
216
  Reach for `createInMemoryStorage()` when a service takes a `StorageService` directly — it builds the same pair.
@@ -216,21 +229,16 @@ it('asks for confirmation on the first round', async () => {
216
229
  });
217
230
  ```
218
231
 
219
- To assert on *what* was requested, catch it and read `error.result` — the `input_required` result the handler factory would have returned:
232
+ To assert on *what* was requested, use `expectInputRequired` from `/testing`. It runs the handler and returns the `input_required` result the handler factory would have returned; it throws when the handler returns normally, and any other error propagates untouched:
220
233
 
221
234
  ```ts
222
- async function requestedInput(input: ToolInput, options: MockContextOptions = {}) {
223
- try {
224
- await myTool.handler(input, createMockContext(options));
225
- } catch (error) {
226
- if (isInputRequiredSignal(error)) return error.result;
227
- throw error;
228
- }
229
- throw new Error('Expected the handler to request input.');
230
- }
235
+ import { createMockContext, expectInputRequired } from '@cyanheads/mcp-ts-core/testing';
236
+
237
+ const asked = await expectInputRequired(() => myTool.handler(input, createMockContext()));
238
+ expect(asked.inputRequests?.confirm?.method).toBe('elicitation/create');
231
239
  ```
232
240
 
233
- `inputResponses` drives the second round. `ctx.inputs.accepted(key, schema)` and `.view(key)` read it with the same helpers production uses, so a wrong response shape fails in the test:
241
+ Pass `asked.requestState` back as `createMockContext({ requestState })` when the handler reads state from the prior round. `inputResponses` drives the second round. `ctx.inputs.accepted(key, schema)` and `.view(key)` read it with the same helpers production uses, so a wrong response shape fails in the test:
234
242
 
235
243
  ```ts
236
244
  it('proceeds once the user accepts', async () => {
@@ -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.11"
7
+ version: "2.12"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -47,7 +47,7 @@ Utility exports from `@cyanheads/mcp-ts-core/utils`. Utilities with complex APIs
47
47
  | Export | API | Notes |
48
48
  |:-------|:----|:------|
49
49
  | `extractCursor` | `(params?) -> string \| undefined` | Extracts opaque cursor string from MCP request params. Checks `params.cursor` then `params._meta.cursor`. Returns `undefined` when no cursor is present. Does not decode. |
50
- | `paginateArray` | `<T>(items, cursorStr, defaultPageSize, maxPageSize, context: RequestContext) -> PaginatedResult<T>` | Decodes cursor, slices array, returns `{ items, nextCursor?, totalCount }`. `nextCursor` omitted on last page. Throws `McpError(InvalidParams)` on invalid cursor. |
50
+ | `paginateArray` | `<T>(items, cursorStr, defaultPageSize, maxPageSize, context: RequestContext) -> PaginatedResult<T>` | Decodes cursor, slices array, returns `{ items, nextCursor?, totalCount }`. `nextCursor` omitted on last page. Throws `McpError(InvalidParams)` on invalid cursor. On a continued call the page size comes from the cursor, not `defaultPageSize` — a tool with a caller-facing `limit` input must slice on `decodeCursor(...).offset` itself to honor `limit` past page 1. |
51
51
  | `encodeCursor` | `(state: PaginationState) -> string` | Encodes `{ offset, limit, ...extra }` to opaque base64url string. |
52
52
  | `decodeCursor` | `(cursor, context: RequestContext) -> PaginationState` | Decodes opaque base64url cursor. Throws `McpError(InvalidParams)` if malformed. |
53
53
 
@@ -177,6 +177,6 @@ Helper API only. For the catalog of what the framework auto-emits (span names, m
177
177
 
178
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.
179
179
 
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.
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
 
182
182
  Standard OTel semantic conventions (HTTP, cloud, service, network, etc.) are NOT re-exported — import those directly from `@opentelemetry/semantic-conventions` if needed.
@@ -8,16 +8,16 @@ import { sanitization, RateLimiter, IdGenerator, idGenerator, generateUUID, gene
8
8
 
9
9
  ## `sanitization`
10
10
 
11
- Pre-constructed singleton of `Sanitization`. Tier 3 peers: `sanitize-html`, `validator` (install as needed per method).
11
+ Pre-constructed singleton of `Sanitization`. Tier 3 peer: `sanitize-html` (HTML handling only); URL and number validation are built in.
12
12
 
13
13
  ### Methods
14
14
 
15
15
  | Method | Async | Peer dep | Signature |
16
16
  |:-------|:------|:---------|:----------|
17
17
  | `sanitizeHtml` | yes | `sanitize-html` | `(input, config?) -> Promise<string>` |
18
- | `sanitizeString` | yes | `sanitize-html` / `validator` | `(input, options?) -> Promise<string>` |
19
- | `sanitizeUrl` | yes | `validator` | `(input, allowedProtocols?) -> Promise<string>` |
20
- | `sanitizeNumber` | yes | `validator` (string input) | `(input, min?, max?) -> Promise<number>` |
18
+ | `sanitizeString` | yes | `sanitize-html` (`'text'`, `'html'`, `'attribute'` contexts) | `(input, options?) -> Promise<string>` |
19
+ | `sanitizeUrl` | yes | none | `(input, allowedProtocols?) -> Promise<string>` |
20
+ | `sanitizeNumber` | yes | none | `(input, min?, max?) -> Promise<number>` |
21
21
  | `sanitizePath` | **no** | Node.js only | `(input, options?) -> SanitizedPathInfo` |
22
22
  | `sanitizeJson` | **no** | none | `<T>(input, maxSize?) -> T` |
23
23
  | `sanitizeForLogging` | **no** | none | `(input) -> unknown` |
@@ -59,7 +59,8 @@ interface SanitizedPathInfo {
59
59
 
60
60
  - `sanitizeHtml`: returns `''` for falsy input; `<a>` tags get `rel="noopener noreferrer"` by default
61
61
  - `sanitizeString`: `'javascript'` context always throws `McpError(ValidationError)` — no JavaScript allowed
62
- - `sanitizeUrl`: default protocols `['http', 'https']`; always blocks `javascript:`, `data:`, `vbscript:`
62
+ - `sanitizeUrl`: default protocols `['http', 'https']`; requires a host (domain, single-label name such as `localhost`, IPv4 dotted quad, or bracketed IPv6), so host-less schemes like `mailto:` never pass; rejects whitespace, `<`, `>`, and URLs over 2084 characters; always blocks `javascript:`, `data:`, `vbscript:`
63
+ - `sanitizeNumber`: string input must be a plain decimal — optional sign and fraction, no exponent (`1e5`) or separators (`1,000`)
63
64
  - `sanitizePath`: **Node-only** — throws `McpError(InternalError)` in Workers. Throws `McpError(ValidationError)` on path traversal or null bytes.
64
65
  - `sanitizeJson`: `maxSize` is bytes (UTF-8); uses `Buffer.byteLength` / `TextEncoder` / `string.length` fallback chain
65
66
  - `sanitizeNumber`: `NaN`/`Infinity` always rejected; out-of-range values silently clamped with debug log
@@ -81,7 +82,7 @@ const clean = await sanitization.sanitizeHtml(userHtml, {
81
82
  });
82
83
 
83
84
  // URL validation
84
- const safeUrl = await sanitization.sanitizeUrl(userUrl, ['http', 'https', 'mailto']);
85
+ const safeUrl = await sanitization.sanitizeUrl(userUrl, ['http', 'https', 'ftp']);
85
86
 
86
87
  // Path sanitization (Node-only)
87
88
  const info = sanitization.sanitizePath(userPath, { rootDir: '/app/data', allowAbsolute: false });