@cyanheads/mcp-ts-core 0.13.2 → 0.13.4

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 (117) hide show
  1. package/AGENTS.md +12 -10
  2. package/CLAUDE.md +12 -10
  3. package/README.md +2 -2
  4. package/changelog/0.13.x/0.13.3.md +44 -0
  5. package/changelog/0.13.x/0.13.4.md +65 -0
  6. package/changelog/template.md +7 -7
  7. package/dist/config/appRoot.d.ts.map +1 -1
  8. package/dist/config/appRoot.js +48 -16
  9. package/dist/config/appRoot.js.map +1 -1
  10. package/dist/core/app.d.ts +20 -0
  11. package/dist/core/app.d.ts.map +1 -1
  12. package/dist/core/app.js +1 -0
  13. package/dist/core/app.js.map +1 -1
  14. package/dist/core/index.d.ts +1 -0
  15. package/dist/core/index.d.ts.map +1 -1
  16. package/dist/core/index.js.map +1 -1
  17. package/dist/linter/rules/schema-rules.d.ts +19 -0
  18. package/dist/linter/rules/schema-rules.d.ts.map +1 -1
  19. package/dist/linter/rules/schema-rules.js +36 -0
  20. package/dist/linter/rules/schema-rules.js.map +1 -1
  21. package/dist/linter/rules/tool-rules.d.ts +17 -0
  22. package/dist/linter/rules/tool-rules.d.ts.map +1 -1
  23. package/dist/linter/rules/tool-rules.js +97 -1
  24. package/dist/linter/rules/tool-rules.js.map +1 -1
  25. package/dist/mcp-server/handlerContext.d.ts +6 -0
  26. package/dist/mcp-server/handlerContext.d.ts.map +1 -1
  27. package/dist/mcp-server/handlerContext.js.map +1 -1
  28. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
  29. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +17 -9
  30. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
  31. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts +114 -0
  32. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts.map +1 -0
  33. package/dist/mcp-server/tools/utils/inputPrevalidation.js +428 -0
  34. package/dist/mcp-server/tools/utils/inputPrevalidation.js.map +1 -0
  35. package/dist/mcp-server/tools/utils/strictenRecord.d.ts +42 -0
  36. package/dist/mcp-server/tools/utils/strictenRecord.d.ts.map +1 -0
  37. package/dist/mcp-server/tools/utils/strictenRecord.js +48 -0
  38. package/dist/mcp-server/tools/utils/strictenRecord.js.map +1 -0
  39. package/dist/mcp-server/tools/utils/toolDefinition.d.ts +27 -0
  40. package/dist/mcp-server/tools/utils/toolDefinition.d.ts.map +1 -1
  41. package/dist/mcp-server/tools/utils/toolDefinition.js +35 -6
  42. package/dist/mcp-server/tools/utils/toolDefinition.js.map +1 -1
  43. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +28 -4
  44. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  45. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +245 -23
  46. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  47. package/dist/services/canvas/core/sqlGate.d.ts +14 -1
  48. package/dist/services/canvas/core/sqlGate.d.ts.map +1 -1
  49. package/dist/services/canvas/core/sqlGate.js +69 -7
  50. package/dist/services/canvas/core/sqlGate.js.map +1 -1
  51. package/dist/services/canvas/index.d.ts +1 -1
  52. package/dist/services/canvas/index.d.ts.map +1 -1
  53. package/dist/services/canvas/index.js +1 -1
  54. package/dist/services/canvas/index.js.map +1 -1
  55. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts +20 -0
  56. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts.map +1 -1
  57. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js +75 -25
  58. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js.map +1 -1
  59. package/dist/services/canvas/types.d.ts +6 -1
  60. package/dist/services/canvas/types.d.ts.map +1 -1
  61. package/dist/types-global/errors.d.ts.map +1 -1
  62. package/dist/types-global/errors.js +4 -3
  63. package/dist/types-global/errors.js.map +1 -1
  64. package/dist/utils/index.d.ts +3 -2
  65. package/dist/utils/index.d.ts.map +1 -1
  66. package/dist/utils/index.js +3 -2
  67. package/dist/utils/index.js.map +1 -1
  68. package/dist/utils/internal/error-handler/errorHandler.d.ts +33 -0
  69. package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
  70. package/dist/utils/internal/error-handler/errorHandler.js +45 -3
  71. package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
  72. package/dist/utils/network/httpError.d.ts +13 -2
  73. package/dist/utils/network/httpError.d.ts.map +1 -1
  74. package/dist/utils/network/httpError.js +4 -2
  75. package/dist/utils/network/httpError.js.map +1 -1
  76. package/dist/utils/network/pacer.d.ts +117 -0
  77. package/dist/utils/network/pacer.d.ts.map +1 -0
  78. package/dist/utils/network/pacer.js +304 -0
  79. package/dist/utils/network/pacer.js.map +1 -0
  80. package/dist/utils/network/retry.d.ts +119 -3
  81. package/dist/utils/network/retry.d.ts.map +1 -1
  82. package/dist/utils/network/retry.js +176 -35
  83. package/dist/utils/network/retry.js.map +1 -1
  84. package/dist/utils/security/rateLimiter.d.ts +19 -1
  85. package/dist/utils/security/rateLimiter.d.ts.map +1 -1
  86. package/dist/utils/security/rateLimiter.js +49 -1
  87. package/dist/utils/security/rateLimiter.js.map +1 -1
  88. package/dist/utils/telemetry/attributes.d.ts +19 -0
  89. package/dist/utils/telemetry/attributes.d.ts.map +1 -1
  90. package/dist/utils/telemetry/attributes.js +30 -0
  91. package/dist/utils/telemetry/attributes.js.map +1 -1
  92. package/framework-skills/add-tool/SKILL.md +43 -2
  93. package/framework-skills/api-canvas/SKILL.md +8 -4
  94. package/framework-skills/api-config/SKILL.md +4 -4
  95. package/framework-skills/api-errors/SKILL.md +19 -10
  96. package/framework-skills/api-linter/SKILL.md +48 -3
  97. package/framework-skills/api-telemetry/SKILL.md +26 -2
  98. package/framework-skills/api-utils/SKILL.md +7 -3
  99. package/framework-skills/api-utils/references/security.md +2 -2
  100. package/framework-skills/design-mcp-server/SKILL.md +17 -2
  101. package/framework-skills/field-test/SKILL.md +3 -1
  102. package/framework-skills/git-wrapup/SKILL.md +90 -68
  103. package/framework-skills/orchestrations/SKILL.md +3 -3
  104. package/framework-skills/release-and-publish/SKILL.md +10 -10
  105. package/framework-skills/release-pr-review/SKILL.md +19 -26
  106. package/framework-skills/report-issue-framework/SKILL.md +6 -35
  107. package/framework-skills/report-issue-local/SKILL.md +6 -36
  108. package/framework-skills/tool-defs-analysis/SKILL.md +4 -4
  109. package/package.json +7 -7
  110. package/templates/.github/ISSUE_TEMPLATE/bug_report.yml +2 -2
  111. package/templates/.github/ISSUE_TEMPLATE/feature_request.yml +2 -2
  112. package/templates/.github/workflows/codeql.yml +39 -0
  113. package/templates/AGENTS.md +3 -1
  114. package/templates/CLAUDE.md +3 -1
  115. package/templates/changelog/template.md +7 -7
  116. package/templates/package.json +2 -2
  117. package/templates/src/index.ts +4 -3
@@ -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.26"
7
+ version: "2.27"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -277,6 +277,45 @@ Two limits worth knowing when you write a schema:
277
277
  - **An explicit opening wins.** A definition that declared `.passthrough()` or `.catchall(...)` asked for an open object, and `tool()` leaves it alone. Use that (deliberately) for tools that proxy arbitrary upstream query parameters.
278
278
  - **A union root is strictened per variant.** See below — the branch is where the properties live, so that is where `additionalProperties: false` lands.
279
279
 
280
+ **Declare `.strict()` before `.describe()` / `.meta()` on the root.** Zod keys both to the schema *instance*, and `.strict()` clones without it — so `z.object({…}).describe('…')` loses the description when `tool()` strictens, and the advertised `inputSchema` carries none. `z.object({…}).strict().describe('…')` keeps it, because an already-strict schema is returned untouched. `lint:mcp` reports the loss as `schema-root-meta-discarded`. It bites the root only (and each variant of a union root); field- and nested-level describes are unaffected.
281
+
282
+ ### Three things the framework fixes before the schema sees the arguments
283
+
284
+ Strict input is right for a misspelling the caller can fix, and wrong when the arguments the model wrote were correct and something between the model and the schema was not. An ordered step inside `parseToolArguments` covers those cases: **drop client-added keys → key aliases → parse → on failure, repair and one re-parse.** All three stages are on by default, none changes what `tools/list` advertises, and none appears in a response — each emits a debug log and a counter (`mcp.input.ignored_key`, `mcp.input.aliased`, `mcp.input.coerced`) instead, so a new client artifact surfaces in telemetry rather than as a failed call.
285
+
286
+ **1. Client-added root keys are dropped.** Some clients put their own keys inside `arguments`: a placeholder when the model sends none, a call description, a call id, or a `_meta` block that belongs on `params`. The model never wrote them and cannot remove them, so the retry fails identically. An undeclared root key is dropped when it is underscore-prefixed or on the built-in list (`_meta`, `tool_call_description`, `toolCallId`). Three boundaries: a declared key is never dropped (on a union root, that means every variant's keys); an author-opened root is left alone; and a tool declaring any underscore-prefixed key of its own switches the underscore rule off — otherwise a misspelled `_cursor` would vanish silently, which is the failure strict input exists to prevent.
287
+
288
+ **2. A key alias reaches the handler under the canonical name.** Declare the mappings you know:
289
+
290
+ ```ts
291
+ export const drugProfile = tool('drug_profile', {
292
+ input: z.object({ drug: z.string().describe('Generic or brand name.') }),
293
+ inputAliases: { drug_name: 'drug', substance: 'drug' },
294
+ // …
295
+ });
296
+ ```
297
+
298
+ Alongside those, an undeclared key whose case-folded form (`-`/`_` stripped, lowercased) names exactly one declared key is rewritten too — `max_results`, `Max-Results`, and `MAXRESULTS` all reach a declared `maxResults`, with nothing declared. Neither half advertises anything: `inputSchema` is byte-identical with or without `inputAliases`, so the canonical key keeps its place in `required` and the model is still told to use it.
299
+
300
+ Declare an alias where the meaning is certain and the mapping is one-to-one — a sibling tool's spelling for the same concept, the upstream API's own name, a shorthand weaker models reach for. It is not fuzzy matching: a key matching no alias and no declared key is still rejected by name, with the accepted-key hint. Four boundaries: a rewrite applies only when the target key is absent (alias *and* target present fails exactly as it does today); an author-opened root is never rewritten; a union root resolves against the variant the discriminator selects, and rewrites nothing when the discriminator is absent or unrecognized; and a `headerParam`-designated target is never rewritten *to* — the SDK cross-checks the `Mcp-Param-<Name>` header against the raw body before dispatch, so a later rewrite would hand your handler a value no intermediary attested. `lint:mcp` rejects an alias that shadows a declared key, names a target that does not exist, or is ambiguous against another alias or key (`input-alias-conflict`).
301
+
302
+ **3. A stringified array is repaired after the parse fails.** `statusFilter: "[\"RECRUITING\"]"` against `z.array(z.string())` is a serialization slip the server can undo with certainty — `JSON.parse` is the exact inverse of the `JSON.stringify` that produced it, which is what separates it from the nearest-key guessing strict input refuses. The repair runs *only* on the failure branch, *only* at the paths the rejection's own issues name, and is kept only if the repaired arguments then pass your schema. So it cannot touch a value that was already valid — a free-text field legitimately holding `"[1,2,3]"` is not in the issue list, so it survives untouched even when the same call carries a genuine stringified array in another field. It walks values only: no key is added, dropped, or renamed. When nothing validates, the original rejection is thrown verbatim: same code, message, `data.issues`, and `data.recovery.hint`.
303
+
304
+ Turn any stage off per server — there is no per-tool switch:
305
+
306
+ ```ts
307
+ await createApp({
308
+ input: {
309
+ ignoreKeys: ['some_client_field'], // adds to the built-in list; `false` disables the stage
310
+ caseStyleAliases: false, // declared `inputAliases` only
311
+ coerce: false, // never retry a failed parse
312
+ },
313
+ tools: allToolDefinitions,
314
+ });
315
+ ```
316
+
317
+ Those three stages are the whole of the framework's input edge: argument **key** names, and one **value** shape — a JSON-stringified array, which `JSON.parse` inverts with certainty. Every other value normalization is domain knowledge and belongs to the tool: the case or bare-leaf form of a code, a unit or vocabulary alias, a composite identifier assembled from two arguments, a delimiter-joined list, a spelled-out name. Which variants a given input accepts is decided per input at design time (`design-mcp-server` § *Parameter descriptions*) and applied at the head of the handler, on the unambiguous mappings only.
318
+
280
319
  ### Multi-mode tools take a discriminated-union input
281
320
 
282
321
  When a tool has genuinely exclusive argument sets — look up by ID *or* search by name, never both — declare the union directly instead of making every field optional and checking the combination by hand:
@@ -594,6 +633,8 @@ format: (result) => [{
594
633
  }],
595
634
  ```
596
635
 
636
+ **A parsed value is the same problem one step later.** `Number(raw)` over an absent or non-numeric upstream field yields `NaN`; a missing nested path yields `null` or `undefined`. Against a required `z.number()` / `z.string()` each of those fails the effective-output parse, and the agent gets an internal error in place of a record the tool otherwise had. Guard where the value is parsed, not at the schema: when a documented-sparse feed supplies nothing usable for a field, omit it (declare it `.optional()`, render it `Not available`) rather than passing a `NaN`, a `null`, or a coerced `0` into the return. Decide per field which upstream absences are expected — the honesty rule above, applied to values the server computes rather than copies.
637
+
597
638
  ### Error classification and messaging
598
639
 
599
640
  **Recommended: declare an `errors[]` contract.** A typed contract surfaces in `tools/list` and gives the handler a typed `ctx.fail(reason, …)` keyed by the declared reason union — TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated and tamper-proof, and the linter enforces conformance against the handler body.
@@ -818,7 +859,7 @@ return { items: hits };
818
859
  - [ ] `handler(input, ctx)` is pure — throws on failure, no try/catch (exception: batch tools with per-item isolation use try/catch inside the loop — that's intentional, don't remove it)
819
860
  - [ ] `format()` renders every field in the output schema — enforced at lint time via sentinel injection, startup fails with `format-parity` errors otherwise. Different clients forward different surfaces (Claude Code → `structuredContent`, Claude Desktop → `content[]`); both must carry the same data. Primary fix: render the missing field in `format()` (use `z.discriminatedUnion` for list/detail variants). Escape hatch: if the output schema was over-typed for a genuinely dynamic upstream API, relax it (`z.object({}).passthrough()`) rather than maintaining aspirational typing
820
861
  - [ ] Agent-facing context (empty-result notices, query/filter echo, pagination totals) declared in an `enrichment` block and populated via `ctx.enrich(...)` — reaches both `structuredContent` and `content[]` automatically, not authored solely in `format()` text. Enrichment keys disjoint from `output` keys
821
- - [ ] If wrapping external API: output schema and `format()` preserve uncertainty from sparse upstream payloads instead of inventing concrete values
862
+ - [ ] If wrapping external API: output schema and `format()` preserve uncertainty from sparse upstream payloads instead of inventing concrete values, and a parsed `NaN`/`null` is dropped at the parse site rather than passed to a required output field
822
863
  - [ ] `auth` scopes declared if the tool needs authorization
823
864
  - [ ] `errors: [...]` contract declared for the tool's domain-specific failure modes — or block deleted if no domain failures apply (baseline codes bubble freely)
824
865
  - [ ] Error contract declared inline on this tool — not imported from a shared module, even when other tools have near-identical entries
@@ -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.1"
7
+ version: "2.2"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -148,10 +148,14 @@ await instance.registerTable('recent_fetch', rows, { ttlMs: 30 * 60 * 1000 });
148
148
 
149
149
  Run SQL across registered tables. Returns at most `rowLimit` rows (default 10 000). When the result exceeds `rowLimit`, the response carries `truncated: true` and `rowCount` reflects the number of materialized rows (not the full result set). For full result sets and exact counts, pass `registerAs` — the result is materialized as a new canvas table; the response carries a `preview` slice and the exact `rowCount`.
150
150
 
151
- Querying a table that does not exist throws `NotFound` (`data.reason: 'missing_table'`) with a recovery hint to re-stage the table or call `describe()`. This happens when a table has expired (per-table TTL), been dropped, or the name is mistyped. The error is `NotFound`, not `ValidationError` — agents should re-stage, not fix the SQL shape. An unknown or expired `canvas_id` fails the same way (`data.reason: 'canvas_not_found'`, with its own recovery hint) — thrown by `acquire()` and every canvas operation.
151
+ Querying a table that does not exist throws `NotFound` (`data.reason: 'missing_table'`) with a recovery hint to re-run the tool that staged the table or list what is currently staged. This happens when a table has expired (per-table TTL), been dropped, or the name is mistyped. The error is `NotFound`, not `ValidationError` — agents should re-stage, not fix the SQL shape. An unknown or expired `canvas_id` fails the same way (`data.reason: 'canvas_not_found'`, with its own recovery hint) — thrown by `acquire()` and every canvas operation.
152
152
 
153
153
  A `SELECT` that parses but fails to prepare for any other reason — a mistyped column, an unknown function, an invalid expression — throws `ValidationError` (`data.reason: 'invalid_sql'`) and preserves the DuckDB binder detail in `data.binderMessage` (e.g. `Referenced column "x" not found...`, often with a candidate suggestion). This is distinct from `non_select_statement`, reserved for statements that genuinely aren't `SELECT`s — here the shape is fine, so the agent should fix the named column or function.
154
154
 
155
+ A `SELECT` that prepares and then fails on the staged data throws `ValidationError` (`data.reason: 'sql_execution_error'`) with the engine message preserved and a hint pointing at `TRY_CAST` or filtering the offending rows. The split follows DuckDB's own execution-error classes — `Conversion Error`, `Invalid Input Error`, `Out of Range Error` — matched on the message prefix. Engine faults (`IO Error`, `INTERNAL Error`, `Out of Memory Error`, and anything unmatched) stay `DatabaseError`, so an export or import failing on I/O is never reported to the caller as bad SQL. `DUCKDB_ERROR_REASONS` exports these alongside `SQL_GATE_REASONS`.
156
+
157
+ **Every gate and engine rejection carries `data.recovery.hint`**, which the framework mirrors into `content[]` as a `Recovery:` line — so the guidance reaches `structuredContent`-only and `content[]`-only clients alike. The hints name a capability, never a framework method: an MCP client sees only the consuming server's tool names, so `registerTable()` or `describe()` in a hint is guidance it cannot follow. Write your own hints the same way (see `api-errors`).
158
+
155
159
  ```ts
156
160
  const result = await instance.query(`
157
161
  SELECT germplasmName, COUNT(*) AS n
@@ -230,7 +234,7 @@ await instance.export('g_with_obs', { format: 'csv', stream: writableStream });
230
234
 
231
235
  ```ts
232
236
  const tables = await instance.describe();
233
- // [{ name: 'germplasm', kind: 'table', rowCount: 200, approxSizeBytes: 8192, columns: [...] }, ...]
237
+ // [{ name: 'germplasm', kind: 'table', rowCount: 200, columns: [...] }, ...]
234
238
 
235
239
  // Filter by kind ('table' | 'view').
236
240
  const onlyViews = await instance.describe({ kind: 'view' });
@@ -241,7 +245,7 @@ await instance.clear(); // returns count dropped (drops views b
241
245
 
242
246
  `TableInfo.kind` discriminates `'table'` vs `'view'`. For views, `rowCount` is materialized at describe time via `COUNT(*)` — not free; treat as an approximation if the view is expensive.
243
247
 
244
- `TableInfo.approxSizeBytes` is set for base tables (DuckDB's `estimated_size` from `duckdb_tables()`). It is `undefined` for views views have no entry in `duckdb_tables()`. Use it to decide what to drop when a canvas approaches its memory limit.
248
+ `TableInfo.approxSizeBytes` is `@deprecated` and never populated. DuckDB exposes no per-table byte footprint, so there is no size figure to report and no size-based eviction heuristic to build on; `rowCount` and the canvas memory limit are what `describe()` gives you. The member stays on the type so existing readers compile, and goes away in a future major.
245
249
 
246
250
  ### Cancellation
247
251
 
@@ -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.18"
7
+ version: "1.19"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -28,7 +28,7 @@ Managed by `@cyanheads/mcp-ts-core`. Validated via Zod from environment variable
28
28
  3. `sessionMode.default` passed to `createApp()` — a default, so it sits *below* the env var it seeds, unlike the identity options above
29
29
  4. `package.json` fields
30
30
 
31
- **Where `package.json` is read from:** the application root — the nearest `package.json` at or above the process entry module (`process.argv[1]`), which is the served package on every launch path (`npx`, `.mcpb`, a client config naming `dist/index.js`), none of which run from the package root. The launching client's working directory is never the anchor: a stdio client starts the server from wherever it happens to be, so reading identity from there makes a server report a foreign project's name and version. When the entry module is a tool installed under the project's own `node_modules` and the process runs from that project — a test runner is the usual case — the project's manifest wins. With no manifest reachable, the framework's own identity is the fallback.
31
+ **Where `package.json` is read from:** the application root — the nearest `package.json` at or above the process entry module (`process.argv[1]`), which is the served package on every launch path (`npx`, `.mcpb`, a client config naming `dist/index.js`), none of which run from the package root. The launching client's working directory is never the anchor: a stdio client starts the server from wherever it happens to be, so reading identity from there makes a server report a foreign project's name and version. When the entry module is a tool installed under a `node_modules` tree and the process runs from the directory owning that tree — a test runner is the usual case — the nearest manifest at or above the working directory wins instead. That also covers a workspace monorepo, where the runner is hoisted to the repo root while the process runs from a package directory: an owner that is a strict *ancestor* of the working directory qualifies only when it declares a workspace (a `workspaces` field in its manifest, or a `pnpm-workspace.yaml` beside it), which is what keeps a cache prefix or a plain project root — equally ancestors of a working directory inside them — from overriding an installed package's own identity. The owner is the outermost `node_modules` boundary, so a transitively-installed runner and a pnpm isolated layout resolve the same way. With no manifest reachable, the framework's own identity is the fallback.
32
32
 
33
33
  ---
34
34
 
@@ -276,7 +276,7 @@ export function getServerConfig(): ServerConfig {
276
276
  }
277
277
  ```
278
278
 
279
- **Env booleans — use `z.stringbool()`, never `z.coerce.boolean()`.** `z.coerce.boolean()` runs `Boolean(value)`, so `"false"`, `"0"`, and `"no"` all coerce to `true` — the flag becomes impossible to disable through the environment except by omitting it entirely. `z.stringbool()` parses `true/false/1/0/yes/no/on/off` (case-insensitive) and rejects anything else, so `MY_VERBOSE_LOGGING=false` actually disables and a typo fails loudly at startup instead of silently coercing. Empty string and unset both fall through to `.default()`.
279
+ **Env booleans — use `z.stringbool()`, never `z.coerce.boolean()`.** `z.coerce.boolean()` runs `Boolean(value)`, so `"false"`, `"0"`, and `"no"` all coerce to `true` — the flag becomes impossible to disable through the environment except by omitting it entirely. `z.stringbool()` parses `true/false/1/0/yes/no/on/off` (case-insensitive) and rejects anything else, so `MY_VERBOSE_LOGGING=false` actually disables and a typo fails loudly at startup instead of silently coercing. An empty string is not in that accepted set — `z.stringbool()` rejects `''` with `Invalid option`. What makes a blank `.env` line take the default is the normalization layer described under **Unset means unset** below, not the schema type.
280
280
 
281
281
  **Unset means unset.** `parseEnvConfig` and the framework's own config both treat an empty string and a whole-value `${…}` placeholder — what an MCPB or plugin host forwards when a user leaves an option blank and nothing substitutes it — as the variable being absent: an optional field stays `undefined`, a defaulted field takes its default, and a required field fails as missing rather than as a format error against the literal text. A value that merely contains `${…}` is kept. No per-field `z.preprocess` guard is needed for either case.
282
282
 
@@ -289,6 +289,6 @@ Server config validation failed:
289
289
 
290
290
  Instead of a raw `ZodError` dump at startup. The framework catches the resulting `ConfigurationError` and prints a clean banner (full stack behind `DEBUG=true`).
291
291
 
292
- Direct `ServerConfigSchema.parse(...)` still works — the framework intercepts raw `ZodError` thrown from `setup()` and converts it — but error messages won't know about env var names, so they show the Zod path (`apiKey`) instead of the variable name (`MY_API_KEY`).
292
+ Direct `ServerConfigSchema.parse(...)` still works — the framework intercepts raw `ZodError` thrown from `setup()` and converts it — but error messages won't know about env var names, so they show the Zod path (`apiKey`) instead of the variable name (`MY_API_KEY`). No normalization runs on that path either, so a blank `MY_FLAG=` arrives as `''` and fails validation. `normalizeEnv` is exported from `/config` for exactly that case: normalize the values first, then parse.
293
293
 
294
294
  **Workers:** Do not parse `process.env` at module top-level. In Workers, env bindings are injected at request time via `injectEnvVars()`, after all static imports. Lazy parsing is required.
@@ -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.10"
7
+ version: "1.13"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -116,6 +116,8 @@ throw ctx.fail('no_match', `No item ${id}`, {
116
116
  });
117
117
  ```
118
118
 
119
+ > **A recovery hint names a capability, never an internal method.** The reader is a model whose only reachable surface is this server's tool names — it cannot call a TypeScript method, set a library option, or re-run an internal function. `Re-stage the table via registerTable()` is unfollowable and invites a hallucinated tool call; `Re-run the tool that produced this table to stage it again, or list the currently staged tables with this server's dataframe-describe tool` is actionable from where the reader sits. Name a condition the caller cannot observe — an option flag they never set — and the hint is noise for the same reason. The framework holds its own throws to this rule: the canvas SQL gate's rejections point at the dataframe-query and dataframe-describe capabilities rather than the provider methods behind them.
120
+
119
121
  `ctx.recoveryFor` is the first member of a planned **family of opt-in resolution helpers**. Future contract-bound fields (`troubleshootingFor`, `userMessageFor`, …) follow the same shape: single-purpose, spreadable wire-shape, `{}` fallback when not applicable.
120
122
 
121
123
  **Skip the contract** for one-off internal tools or quick prototypes — `ctx` is plain `Context` (no `fail`) and you throw via [factories](#error-factories-fallback) directly. Behavior is identical at the wire; the contract just adds compile-time safety.
@@ -272,13 +274,16 @@ Use factories or `McpError` directly when the code must be exact — auto-classi
272
274
 
273
275
  The framework applies these steps in order — first match wins:
274
276
 
275
- 1. **`McpError` instance** — `error.code` is preserved as-is; no classification needed.
276
- 2. **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 5 and read as a `Timeout`.
277
- 3. **JS constructor name** — matched against a fixed table (e.g. `ZodError` → `ValidationError`, `SyntaxError` `ValidationError`). Note: `TypeError` is intentionally excludedruntime TypeErrors are programmer errors, not validation failures.
278
- 4. **Provider-specific patterns** — HTTP status codes, AWS exception names, Supabase, OpenRouter. Checked before common patterns because they are more specific (e.g. `status code 429` beats the generic `rate limit` pattern).
279
- 5. **Common message/name patterns** — broad keyword patterns covering auth, not-found, validation, etc. First match wins; order matters.
280
- 6. **`AbortError` name** — `error.name === 'AbortError'` `Timeout`.
281
- 7. **Fallback** — `InternalError`.
277
+ 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.
278
+ 2. **`McpError` instance** — `error.code` is preserved as-is; no classification needed.
279
+ 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 scopea service, an outbound leg, a background task.
280
+ 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.
281
+ 5. **Provider-specific patterns** — HTTP status codes, AWS exception names, Supabase, OpenRouter. Checked before common patterns because they are more specific (e.g. `status code 429` beats the generic `rate limit` pattern).
282
+ 6. **Common message/name patterns** — broad keyword patterns covering auth, not-found, validation, etc. First match wins; order matters.
283
+ 7. **`AbortError` name** — `error.name === 'AbortError'` → `Timeout`.
284
+ 8. **Fallback** — `InternalError`.
285
+
286
+ However it is reached, a `RequestCancelled` is logged at `info` with no stack — neither the thrown value's own nor one reached through its cause chain. Step 1 settles the completion log too, which carries `metrics.errorCode: "-32011"` alongside `isSuccess: false`; a raw `SdkError` that reaches the code through step 3 alone is not an `McpError`, so that log still reads `UNHANDLED_ERROR`.
282
287
 
283
288
  ### JS Constructor Name Mappings
284
289
 
@@ -363,7 +368,11 @@ Important properties:
363
368
  - **`data` propagation is restricted** to explicitly-thrown `McpError.data` and `ZodError.issues`. Auto-classified plain errors (`TypeError`, network errors, etc.) emit `code` + `message` only — no `data` — so internal classification context never leaks to clients.
364
369
  - **Recovery hint mirroring is automatic.** When the thrown `McpError` carries `data.recovery.hint`, the handler factory appends it to the `content[]` text so the markdown surface matches the JSON surface. Authors don't need to format the hint manually.
365
370
  - **Argument-schema rejection is a tool error with the same envelope.** An unknown root key, a wrong type, a missing required field, or a failed constraint returns `isError: true` with `structuredContent.error.code = -32602` (`InvalidParams`) and the readable `Invalid arguments for tool <name>: …` diagnostic in `content[]`. The handler never runs. Two neighbouring failures keep the protocol error path instead, arriving as a JSON-RPC error rather than a tool result: an unknown or disabled tool name, and a malformed request envelope.
366
- - **A schema constraint cannot carry a declared reason.** Because the handler never runs, a rejection by `.max()`, `.regex()`, `.min()`, or any other Zod refinement bypasses `errors[]` entirely: it arrives as `InvalidParams` with `data.issues` and no `data.reason`, so a caller has nothing to branch on and gets no recovery hint. Decide per constraint which surface it belongs on. A bound that is purely structural — the input is the wrong shape and no guidance beyond the diagnostic would help belongs on the schema, where it also advertises itself in `inputSchema`. A bound a caller is expected to recover from belongs in the handler as `ctx.fail('reason', message, ctx.recoveryFor('reason'))` against a declared `errors[]` entry, with the limit restated in the field's `.describe()` so it is still visible before the call. Enforcing the same bound in both places is the trap: the schema wins, and the contract entry becomes unreachable while still reading as covered.
371
+ - **`invalid_arguments` is the framework-owned reason on every argument rejection.** The rejection carries `data.reason: "invalid_arguments"` and a `data.recovery.hint` the framework synthesizes from the Zod issues, the arguments as sent, and the root schema an unknown key names the root properties the tool does accept, a wrong type names the type to send instead, missing fields collapse into one `Provide …` sentence, and anything else carries its own diagnostic. The hint rides `content[]` as `Recovery: …` like any other, so format-only clients see it too. Authors declare nothing for this: the rejection happens before the handler and the hint is derived from the schema.
372
+ - **A schema constraint cannot carry a *declared* reason.** Because the handler never runs, a rejection by `.max()`, `.regex()`, `.min()`, or any other Zod refinement bypasses `errors[]` entirely: it arrives as `InvalidParams` with `data.issues` under the framework's `invalid_arguments`, never the `reason` and authored `recovery` of a contract entry — so a caller has nothing tool-specific to branch on and gets only the schema-derived hint. Decide per constraint which surface it belongs on. A bound that is purely structural — the input is the wrong shape and no guidance beyond the diagnostic would help — belongs on the schema, where it also advertises itself in `inputSchema`. A bound a caller is expected to recover from belongs in the handler as `ctx.fail('reason', message, ctx.recoveryFor('reason'))` against a declared `errors[]` entry, with the limit restated in the field's `.describe()` so it is still visible before the call. Enforcing the same bound in both places is the trap: the schema wins, and the contract entry becomes unreachable while still reading as covered.
373
+ - **A rejected value never reaches the client.** The rendered sentence distinguishes an omitted field from a wrong one (`what: Missing required field. Expected one of "os"|"cpu"` rather than the invalid-option text), and a union renders the branch that says what would have been accepted instead of Zod's `Invalid input` placeholder. Both read the arguments in-process for the absent/present bit and the arriving type only — `data.issues` ships the Zod issues as-is, and no value the caller sent is copied onto them.
374
+ - **A union branch names its own field.** Each branch issue is prefixed with the path it names relative to that branch, so two alternatives differing only in which field they require stay distinguishable: `spec: kind: Invalid option: expected one of "x"|"y"; n: Invalid input: expected number, received undefined or other: Invalid input: expected string, received undefined`. Issues *within* one branch join on `; `, across branches on ` or `, and top-level issues on `, ` — three nestings, three separators. A scalar branch carries no path and renders as before. `data.issues` still ships the raw nested Zod issues, and `data.recovery.hint` carries the same prefixed text.
375
+ - **Some rejections never happen at all.** An ordered pre-validation step wraps the parse: a client-added root key is dropped, a declared or case-style key alias is rewritten to its canonical name, and — only after a failed parse — a JSON-stringified array is repaired and the arguments parsed once more. A call the step rescues succeeds outright and produces no error envelope; a call it cannot rescue throws the rejection above verbatim, same code, message, `data.issues`, and `data.recovery.hint`. See the `add-tool` skill for the boundaries and the per-server switches.
367
376
 
368
377
  **Handler — throw freely, no try/catch:**
369
378
 
@@ -445,7 +454,7 @@ if (!response.ok) {
445
454
 
446
455
  Captures the response body (truncated, configurable limit) and `Retry-After` header (stored as `data.retryAfter`) into `error.data`. The codes it produces line up with `withRetry`'s transient-code set, so retryable responses are retried automatically.
447
456
 
448
- > **Body reaches the client.** `error.data` is forwarded to the MCP client as `structuredContent.error.data` (tool errors) or JSON-RPC `error.data` (resource errors). Upstream 401/403/422 responses sometimes echo token claims, internal user IDs, or schema validation hints — that text becomes client-visible. For sensitive endpoints, pass `captureBody: false` (or `bodyLimit: 0`) so the body stays out of `data`. Defaults remain `captureBody: true` because most upstreams return useful diagnostic text and silent dropping helps no one debug.
457
+ > **`error.data` reaches the client.** It is forwarded to the MCP client as `structuredContent.error.data` (tool errors) or JSON-RPC `error.data` (resource errors). Upstream 401/403/422 responses sometimes echo token claims, internal user IDs, or schema validation hints — that text becomes client-visible. For sensitive endpoints, pass `captureBody: false` (or `bodyLimit: 0`) so the body stays out of `data`. Defaults remain `captureBody: true` because most upstreams return useful diagnostic text and silent dropping helps no one debug. The upstream **URL** defaults the other way and is omitted, since a request URL routinely carries user input, internal identifiers, or an API key in its query string; `includeUrl: true` puts the full `response.url` on `data.url`. The message names the host either way.
449
458
 
450
459
  Full status table:
451
460
 
@@ -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.14"
7
+ version: "1.15"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -44,10 +44,10 @@ Grouped by family. Jump to any rule ID via its anchor.
44
44
  |:-------|:------|:--------|
45
45
  | Definition | `definition-invalid` | [Definition rules](#definition-rules) |
46
46
  | Format parity | `format-parity`, `format-parity-threw`, `format-parity-walk-failed`, `format-parity-depth-limit` | [Format parity](#format-parity) |
47
- | Schema | `schema-is-object`, `describe-on-fields`, `schema-serializable`, `schema-unsatisfiable`, `header-param-designation` | [Schema rules](#schema-rules) |
47
+ | Schema | `schema-is-object`, `describe-on-fields`, `schema-serializable`, `schema-unsatisfiable`, `header-param-designation`, `schema-root-meta-discarded` | [Schema rules](#schema-rules) |
48
48
  | Portability | `schema-format-portability`, `schema-anyof-needs-type`, `schema-no-discriminator-keyword`, `schema-no-defs`, `schema-root-oneof-portability`, `schema-dialect-tag` | [Portability rules](#portability-rules) |
49
49
  | Names | `name-required`, `name-format`, `name-unique` | [Name rules](#name-rules) |
50
- | Tools | `description-required`, `handler-required`, `auth-type`, `auth-scope-format`, `annotation-type`, `annotation-coherence`, `meta-ui-type`, `meta-ui-resource-uri-required`, `meta-ui-resource-uri-scheme`, `app-tool-resource-pairing`, `canvas-consumer-missing` | [Tool rules](#tool-rules) |
50
+ | Tools | `description-required`, `handler-required`, `auth-type`, `auth-scope-format`, `annotation-type`, `annotation-coherence`, `input-alias-conflict`, `meta-ui-type`, `meta-ui-resource-uri-required`, `meta-ui-resource-uri-scheme`, `app-tool-resource-pairing`, `canvas-consumer-missing` | [Tool rules](#tool-rules) |
51
51
  | Resources | `uri-template-required`, `uri-template-valid`, `resource-name-not-uri`, `template-params-align` | [Resource rules](#resource-rules) |
52
52
  | Landing | `landing-*` (23 rules — shape, tagline, logo, links, repo, envExample, connectSnippets, theme) | [Landing config rules](#landing-config-rules) |
53
53
  | Prompts | `generate-required` | [Prompt rules](#prompt-rules) |
@@ -270,6 +270,29 @@ The message names the offending field in the linter's path vocabulary: `input.ro
270
270
 
271
271
  Silent when the schema cannot be converted to JSON Schema at all — that is `schema-serializable`'s diagnostic.
272
272
 
273
+ ### schema-root-meta-discarded
274
+
275
+ **Severity:** warning
276
+
277
+ Fires when a `.describe()` or `.meta()` on a tool's **input root** was discarded by strictening, so the advertised `inputSchema` does not carry it.
278
+
279
+ Zod keys both calls to the schema *instance*, in `z.globalRegistry`. `.strict()` is `catchall(z.never())` — a clone with no link back to the original — so the strictened schema `tool()` stores inherits no entry. Ordering is therefore load-bearing, and nothing in the type signature says so:
280
+
281
+ ```ts
282
+ z.object({ … }).describe('An object root.') // lost — tool() strictens after
283
+ z.object({ … }).strict().describe('An object root.') // kept — already strict, returned untouched
284
+ ```
285
+
286
+ The loss is otherwise invisible in every direction: `describe-on-fields` never asks a root to describe itself, and `schema-anyof-needs-type` reports on the metadata that *survived*, so a dropped `.meta({ anyOf })` reads as no `anyOf` at all — which matters, because `anyOf` with per-branch `type` is the portable way to publish "one of these argument sets is required".
287
+
288
+ **Fix:** move `.strict()` ahead of `.describe()` / `.meta()` on the root. The message names what was discarded and where: `input` for an object or union root, `input|<i>` for a union variant (a union is rebuilt from its strictened options, so the union's own entry and each rebuilt variant's both go).
289
+
290
+ Silent when nothing was strictened — an explicit `.strict()`, `.passthrough()`, or `.catchall(...)` on the root or on every variant — which is exactly the case that advertises the metadata today. Also silent for a definition assembled without the `tool()` builder, since nothing strictened it.
291
+
292
+ Detection happens inside `tool()`, the only place both the authored and the strictened instance exist; by lint time the definition holds the clone, which carries no registry entry and no way back. The record rides a symbol-keyed, non-enumerable property, so `Object.keys(definition)`, `JSON.stringify(definition)`, `tools/list`, `/.well-known/mcp.json`, and `_meta` are all unchanged.
293
+
294
+ Whether the discarded description or metadata should instead reach the wire is a separate question — that changes the advertised bytes, so it is held.
295
+
273
296
  ---
274
297
 
275
298
  ## Portability rules
@@ -432,6 +455,28 @@ Every element in `auth` must be a non-empty string. Empty strings in the array a
432
455
 
433
456
  Catches `readOnlyHint: true` with **any** explicit `destructiveHint` value (even `false`) — the destructive hint is meaningless on a read-only tool, so its presence signals authoring confusion. Drop `destructiveHint` entirely when the tool is read-only.
434
457
 
458
+ ### input-alias-conflict
459
+
460
+ **Severity:** error
461
+
462
+ Fires when a tool's `inputAliases` cannot resolve to exactly one declared input key. An alias is a one-to-one mapping fixed ahead of time — the reason it is accepted where nearest-key matching is not — so an alias resolving to none or to more than one is a definition error, not a runtime one. The runtime declines an ambiguous rewrite silently and the caller sees the ordinary strict rejection, which reads as the alias simply not working.
463
+
464
+ Five conditions, all decidable from the definition:
465
+
466
+ | Condition | Example |
467
+ |:--|:--|
468
+ | An alias must not equal a declared key | `input: z.object({ q, query })` with `inputAliases: { q: 'query' }` — a declared key is never rewritten, so the alias can never fire |
469
+ | An alias's target must be a declared key | `inputAliases: { q: 'searchQuery' }` when the schema declares `query` |
470
+ | Two declared keys must not case-fold to one name | `z.object({ maxResults, max_results })` — no alias can resolve between them |
471
+ | An alias must not case-fold to a declared key other than its target | `inputAliases: { max_results: 'query' }` alongside a declared `maxResults` |
472
+ | Two aliases must not case-fold to one name with different targets | `inputAliases: { 'search-term': 'query', search_term: 'maxResults' }` |
473
+
474
+ Case-folding strips `-` and `_` and lowercases — the same fold the runtime rewrite applies, so the rule and the runtime cannot disagree. On a discriminated-union root, every variant's keys count as declared: a rewrite resolves against the selected variant, so an alias naming a key no variant declares can never fire.
475
+
476
+ **Fix:** point the alias at an existing key, rename the key it shadows, or drop the alias. Also fires when `inputAliases` is not an object of non-empty string targets.
477
+
478
+ Silent when no `inputAliases` is declared — the case-style half needs no declaration and declines ambiguity on its own.
479
+
435
480
  ### meta-ui-type
436
481
 
437
482
  **Severity:** error (MCP Apps tools only)
@@ -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.9"
7
+ version: "1.11"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -127,6 +127,9 @@ All custom metrics are namespaced `mcp.*` (or `process.*` / `http.client.*` wher
127
127
  | `mcp.tool.input_bytes` | histogram | `bytes` | `mcp.tool.name` |
128
128
  | `mcp.tool.output_bytes` | histogram | `bytes` | `mcp.tool.name` (success only; the handler's returned value) |
129
129
  | `mcp.tool.param.usage` | counter | `{uses}` | `mcp.tool.name`, `mcp.tool.param` (top-level keys supplied by caller) |
130
+ | `mcp.input.ignored_key` | counter | `{keys}` | `mcp.tool.name`, `mcp.input.ignore_rule` (the ignore-list entry that matched, or `underscore_prefix`) |
131
+ | `mcp.input.aliased` | counter | `{keys}` | `mcp.tool.name`, `mcp.input.target` (the declared key), `mcp.input.alias_kind` (`declared`/`case_style`) |
132
+ | `mcp.input.coerced` | counter | `{calls}` | `mcp.tool.name`, `mcp.input.coercion` (`stringified_array`) |
130
133
  | `mcp.resource.reads` | counter | `{reads}` | `mcp.resource.name`, `mcp.resource.success` |
131
134
  | `mcp.resource.duration` | histogram | `ms` | `mcp.resource.name`, `mcp.resource.success` |
132
135
  | `mcp.resource.errors` | counter | `{errors}` | `mcp.resource.name` |
@@ -139,6 +142,27 @@ All custom metrics are namespaced `mcp.*` (or `process.*` / `http.client.*` wher
139
142
  | `mcp.prompt.message_count` | histogram | `{messages}` | `mcp.prompt.name` |
140
143
  | `mcp.requests.active` | up/down counter | `{requests}` | — (in-flight handler executions, all three types) |
141
144
 
145
+ The three `mcp.input.*` counters are the only trace of the pre-validation step a tool call leaves. Each marks a call the strict `input` schema would otherwise have rejected: a client-added root key dropped, a key rewritten to its canonical spelling, or a stringified array repaired after the parse failed (one increment per repaired call, not per repaired value). Nothing about any of them reaches the response, so a client artifact spreading across a fleet shows up here first. All three are lazy: a server whose callers never trip a stage emits no series at all.
146
+
147
+ **Every label is author- or framework-defined — the caller's own key text is never one.** `mcp.input.ignore_rule` is the ignore-list entry that matched or the fixed `underscore_prefix`, bounded by the list's length plus one. `mcp.input.aliased` is labelled by the canonical `mcp.input.target` (a declared property of the tool) and `mcp.input.alias_kind`, not by the alias the caller sent — the case-style half accepts every `-`/`_`/case permutation of a declared key, so labelling the alias would put a caller-controlled set on a permanent series. That is the unbounded-label leak removed from the rate-limiter counter in 0.9.0: a metric attribute set lives until process restart, so anything the caller names belongs on a span or in a log, never on a counter.
148
+
149
+ **To find the raw key, read the debug log**, which carries `ignoredKey` / `alias` alongside the bounded rule and target. The counter tells you a client artifact exists and how often; the log tells you what it is called, which is what you need before extending `input.ignoreKeys`, declaring an `inputAliases` entry, or renaming a parameter.
150
+
151
+ ### Outbound pacer
152
+
153
+ `createPacer` (`/utils`) emits four instruments, all lazy — a server that never queues against an upstream emits no series at all.
154
+
155
+ | Metric | Type | Unit | Attributes |
156
+ |:-------|:-----|:-----|:-----------|
157
+ | `mcp.pacer.queue_depth` | up/down counter | `{requests}` | `mcp.pacer.name` |
158
+ | `mcp.pacer.wait` | histogram | `ms` | `mcp.pacer.name` (enqueue → dispatch, not task duration) |
159
+ | `mcp.pacer.sheds` | counter | `{requests}` | `mcp.pacer.name` (rejected before dispatch — wait budget or queue depth) |
160
+ | `mcp.pacer.cooldowns` | counter | `{cooldowns}` | `mcp.pacer.name` (gate closed by an upstream rate limit) |
161
+
162
+ `mcp.pacer.name` is the **only** attribute on all four — `createPacer({ name })`, set by the server author and bounded by its own configuration. Nothing a caller supplies reaches these series, for the reason above; which upstream call was shed belongs on a span or in a log.
163
+
164
+ Read together: `queue_depth` rising while `wait` climbs means the configured rate is below demand; `sheds` rising against a flat `queue_depth` means callers' `maxWaitMs` budgets are tighter than the window; `cooldowns` rising at all means the upstream is answering 429, so the configured `limits` sit above what it actually grants.
165
+
142
166
  ### Storage, LLM, speech, graph
143
167
 
144
168
  | Metric | Type | Unit | Attributes |
@@ -173,7 +197,7 @@ All custom metrics are namespaced `mcp.*` (or `process.*` / `http.client.*` wher
173
197
  | Metric | Type | Unit | Attributes |
174
198
  |:-------|:-----|:-----|:-----------|
175
199
  | `mcp.errors.classified` | counter | `{errors}` | `mcp.error.classified_code` (JSON-RPC code), `operation` |
176
- | `mcp.ratelimit.rejections` | counter | `{rejections}` | `mcp.rate_limit.key` |
200
+ | `mcp.ratelimit.rejections` | counter | `{rejections}` | — (the rate-limit key is caller-supplied and typically per-client, so it would materialize an unbounded series in the meter; per-key attribution lives on the span instead) |
177
201
  | `http.client.request.duration` | histogram | `s` | `http.request.method`, `server.address`, `http.response.status_code` (when > 0; absent on network errors before a response is received) |
178
202
 
179
203
  ### Process
@@ -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.9"
7
+ version: "2.10"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -32,8 +32,12 @@ Utility exports from `@cyanheads/mcp-ts-core/utils`. Utilities with complex APIs
32
32
  | Export | API | Notes |
33
33
  |:-------|:----|:------|
34
34
  | `fetchWithTimeout` | `(url, timeoutMs, context, options?: FetchWithTimeoutOptions) -> Promise<Response>` | Wraps `fetch` with `AbortController` timeout. `timeoutMs` bounds the **whole exchange**: on a 2xx carrying a body the returned `Response` is a passthrough wrapper that keeps the deadline armed until the body closes, errors, or is cancelled, so a stalled stream rejects the caller's `.text()`/`.json()` with the same `Timeout` error the header phase raises. `status`, `statusText`, `headers`, `url`, `redirected`, and `type` carry across the wrapper; the original body is locked by it, and bodyless/null-body responses (HEAD, 204/205/304) come back untouched. `FetchWithTimeoutOptions` extends `RequestInit` (minus `signal`) and adds `rejectPrivateIPs?: boolean`, `expectedStatuses?: number[]` (listed non-2xx statuses logged at `debug` not `error`, still thrown), `errorBodyLimit?: number` (bytes of a non-2xx body kept, default `500`), and `signal?: AbortSignal` (external cancellation — an abort on it throws `RequestCancelled` (-32011), logged at `info` and outside `withRetry`'s transient set, since the caller is gone and no retry can reach them). On a non-2xx, `error.data` carries `status`/`body` plus the legacy `statusCode`/`responseBody` aliases (identical values; consolidating in a future major); a body over `errorBodyLimit` is captured from both ends — 40% head, 60% tail, joined by `…[N bytes elided]…` — so a diagnostic behind a boilerplate preamble survives the cap, while a body still streaming at the 16 KiB scan ceiling stays head-only with a trailing `…`. SSRF guard (best-effort, not hard isolation): blocks RFC 1918, loopback, link-local, CGNAT, cloud metadata. DNS validation on Node, Bun, and Cloudflare Workers under `nodejs_compat`; hostname-only fallback otherwise. **Both resolvers are queried** — `resolve4`/`resolve6` (c-ares) and `lookup` (the system resolver, which is what reads `/etc/hosts`, split DNS, and NSS modules) — and a non-global answer from either rejects. Runtimes differ in which resolver the connection uses (Bun 1.4 moved `net.connect()` on Linux to `getaddrinfo` while leaving `dns.resolve*()` on c-ares), so checking one alone leaves a name the other can see unguarded; each probe settles independently, so a resolver absent from the runtime is skipped rather than fatal. Manual redirect following (max 5) with per-hop SSRF check. **DNS rebinding / TOCTOU gap** — the validation lookup and `fetch`'s own resolution are independent; pair with egress controls or a DNS-pinning fetch proxy for strong isolation. **Error/log redaction:** URLs written into thrown errors and log lines are reduced to `origin + pathname` — the query string (where API keys commonly ride: `?api-key=…`, `?api_key=…`) never reaches the client or the logs. The actual request still uses the full URL. |
35
- | `withRetry` | `<T>(fn: () => Promise<T>, options?: RetryOptions) -> Promise<T>` | Executes `fn` with exponential backoff. Retries on transient errors (`ServiceUnavailable`, `Timeout`, `RateLimited`); non-transient errors fail immediately. Honors an upstream `Retry-After` on `data.retryAfter` (delta-seconds or HTTP-date) over exponential backoff, capped at `maxDelayMs`; a requested wait beyond the cap fails fast rather than sleeping. On exhaustion, enriches the final error with attempt count in message and `data.retryAttempts`. **Place the retry boundary around the full pipeline** (fetch + parse), not just the network call. `RetryOptions`: `maxRetries` (default `3`), `baseDelayMs` (default `1000`), `maxDelayMs` (default `30000`), `jitter` (default `0.25`), `operation` (log label), `context` (RequestContext), `signal` (AbortSignal), `isTransient` (custom predicate). |
36
- | `httpErrorFromResponse` | `(response: Response, options?: HttpErrorFromResponseOptions) -> Promise<McpError>` | Maps an HTTP `Response` to a properly classified `McpError` full status table including 401/403/408/422/429/5xx, body capture (truncated), `retry-after` header, optional `cause`. `error.data` carries `status`/`body` plus the legacy `statusCode`/`responseBody` aliases (identical values), so a consumer can classify either helper's error without knowing which raised it. Use this instead of hand-rolling `if (status === 429) ...` ladders. Reads the response body — `clone()` first if you need it elsewhere. `HttpErrorFromResponseOptions`: `service?` (logical name in message, e.g. `'NCBI'`), `captureBody?` (default `true`), `bodyLimit?` (default `500`), `data?` (extra fields merged into `error.data`), `cause?`, `codeOverride?` (per-status mapping override). Pairs naturally with `withRetry` both classify codes the same way. A 501 also carries `data.retryable: false`, so retry fails it fast instead of re-asking for a method the upstream does not implement. |
35
+ | `withRetry` | `<T>(fn: (attempt: RetryAttempt) => Promise<T>, options?: RetryOptions) -> Promise<T>` | Executes `fn` with exponential backoff. Retries on transient errors (`ServiceUnavailable`, `Timeout`, `RateLimited`); non-transient errors fail immediately. Honors an upstream `Retry-After` on `data.retryAfter` (delta-seconds or HTTP-date) over exponential backoff, capped at `maxDelayMs`; a requested wait beyond the cap fails fast rather than sleeping. On exhaustion, enriches the final error with attempt count in message and `data.retryAttempts`. **Place the retry boundary around the full pipeline** (fetch + parse), not just the network call. `RetryOptions`: `maxRetries` (default `3`), `baseDelayMs` (default `1000`), `maxDelayMs` (default `30000`), `jitter` (default `0.25`), `operation` (log label), `context` (RequestContext), `signal` (AbortSignal), `isTransient` (custom predicate), `deadlineMs` (total wall-clock budget — see below). |
36
+ | `RetryAttempt` | `{ readonly signal: AbortSignal; readonly remainingMs: number }` | What `fn` receives each attempt. `signal` is `AbortSignal.any` over the `deadlineMs` clock and `options.signal`; `remainingMs` is what is left of the total budget as the attempt starts, never negative and `Number.POSITIVE_INFINITY` when no deadline is set so `Math.min(perAttemptMs, remainingMs)` is correct either way. A zero-argument `fn` stays assignable, so existing callers compile unchanged. |
37
+ | `deadlineMs` | `RetryOptions` field | One wall-clock budget across every attempt, backoff, and honored `Retry-After` — the bound `maxRetries` plus a per-attempt timeout cannot express. Four 30s attempts outlast a client's 60s request timeout, so the caller gets a transport timeout instead of the server's classified error. **Thread `attempt.signal` into the attempt's I/O** (`fetchWithTimeout(url, Math.min(30_000, remainingMs), ctx, { signal })`) or the deadline overshoots by one in-flight request. Clock is `AbortController` + `setTimeout` (never `AbortSignal.timeout()`, per the Bun realm mismatch), cleared on return — no timer outlives the call. Expiry rejects with `Timeout` (-32004) carrying `data: { reason: 'retry_deadline_exceeded', deadlineMs, elapsedMs, retryAttempts }` and the last attempt's error as `cause`; **one shape for every expiry**, including the `RequestCancelled` that an external-signal abort raises inside `fetchWithTimeout` and the raw abort reason a mid-backoff expiry would otherwise surface. No `retryable` flag (a narrower call can still succeed) and no `attempt` index (`retryAttempts` carries it). A backoff that would outlast the remaining budget fails fast with the expiry instead of sleeping into a certain timeout; an honored `Retry-After` that would outlast it takes the `maxDelayMs` exit instead — the attempt's error unchanged, `data.retryAfter` intact, since "wait the window the upstream named" is still the caller's action. **Three clocks stay distinct:** a caller abort on `options.signal` keeps precedence and rethrows unchanged (stamped `RequestCancelled` by the handler factory), a single attempt's timeout is `Timeout` with `errorSource: 'FetchTimeout'` and no `reason`, and the expiry is `Timeout` with the `reason`. Unset, behavior is identical to before — attempt counts, delays, log lines, and the exhausted-error shape untouched. Bounds **one** ladder: a tool making three upstream calls threads its own remaining budget into each. |
38
+ | `defaultIsTransient` | `(error: unknown) -> boolean` | The predicate `withRetry` uses when `isTransient` is omitted: an `McpError` with a transient code (`ServiceUnavailable`, `Timeout`, `RateLimited`) unless it carries `data.retryable === false` or `data.reason === 'pacer_shed'`; any non-`McpError` throw is assumed transient. Exported so `isTransient` — which **replaces** the default outright — can compose instead of mirroring the transient set, which drifts silently when the framework's classification changes: `isTransient: (error) => !isMyBudgetRefusal(error) && defaultIsTransient(error)`, or the inverse `defaultIsTransient(error) \|\| isMyRetryableShape(error)`. The transient code set itself stays private (a module-level `Set` an exported binding could be mutated into framework-wide retry behavior). |
39
+ | `httpErrorFromResponse` | `(response: Response, options?: HttpErrorFromResponseOptions) -> Promise<McpError>` | Maps an HTTP `Response` to a properly classified `McpError` — full status table including 401/403/408/422/429/5xx, body capture (truncated), `retry-after` header, optional `cause`. `error.data` carries `status`/`body` plus the legacy `statusCode`/`responseBody` aliases (identical values), so a consumer can classify either helper's error without knowing which raised it. Use this instead of hand-rolling `if (status === 429) ...` ladders. Reads the response body — `clone()` first if you need it elsewhere. **`error.data` is client-facing** — the framework forwards it verbatim as `structuredContent.error.data` — so the full upstream URL is **omitted by default**: a request URL routinely carries user input, internal identifiers, or an API key in its query string. `includeUrl: true` opts into `data.url` carrying the full `response.url`; with an empty `response.url` no key is added either way, and the message still names the host. `HttpErrorFromResponseOptions`: `service?` (logical name in message, e.g. `'NCBI'`), `captureBody?` (default `true`), `bodyLimit?` (default `500`), `includeUrl?` (default `false`), `data?` (extra fields merged into `error.data`, overriding defaults on key collision — a caller's own `url` still reaches the wire), `cause?`, `codeOverride?` (per-status mapping override). Pairs naturally with `withRetry` — both classify codes the same way. A 501 also carries `data.retryable: false`, so retry fails it fast instead of re-asking for a method the upstream does not implement. |
40
+ | `createPacer` | `(options: PacerOptions) -> Pacer` | FIFO queue in front of one rate-limited upstream — the outbound counterpart to `RateLimiter` (`utils/security`), which is inbound, per-caller, and reject-only, so it cannot queue work against an upstream budget. `pacer.run(task, { signal?, maxWaitMs? })` holds `task` until every `limits` window, `minStartGapMs`, `maxConcurrent`, and the cooldown gate allow it, then calls it with the caller's signal. `PacerOptions`: `name` (author-set telemetry label), `limits` (`{ requests, perMs }[]` — each a sliding window over recorded **start** times, so a slow response never widens the rate the upstream sees; all must allow a start), `minStartGapMs` (**not** expressible through `limits`: `{ requests: 10, perMs: 1000 }` permits ten starts in the same millisecond), `maxConcurrent`, `maxQueueDepth` (absolute backpressure for callers passing no `maxWaitMs`; rejects without arming a timer), `cooldown` (`{ baseMs, maxMs }`). **Shed:** `maxWaitMs` bounds queue time only, never the task. The projected wait is exact over the windows and the gap but a lower bound once `maxConcurrent` binds (a slot frees on an unknowable completion), so enqueue rejects only when that lower bound already exceeds `maxWaitMs` — no false sheds — and a still-queued entry rejects when `maxWaitMs` elapses. The shed error is `rateLimited` (-32003) with `data: { reason: 'pacer_shed', retryAfter, queueDepth }` and **no `retryable: false`** — to the calling agent a shed is an ordinary rate limit (wait `retryAfter`, call again) and that flag would say the opposite; `defaultIsTransient` reads the `reason` instead, so an enclosing `withRetry` fails fast rather than sleeping past the deadline the shed enforces. **Cooldown gate:** a `RateLimited` thrown by the task closes the gate for every queued caller until an absolute instant, `min(max(baseMs · 2^(consecutive−1), retryAfter), maxMs)` — `maxMs` caps both the doubling and an honored `Retry-After`, so a pathological upstream value cannot park the queue. Absent or unparseable `retryAfter` leaves the doubling; any other error leaves the gate open; the first success resets the count. **Composition:** `withRetry(({ signal }) => pacer.run(fn, { signal }), { signal, deadlineMs })` — retry outside, pacer inside, so each attempt re-queues and is re-paced. Because the gate is an absolute instant rather than a duration counted from dequeue, retry's `Retry-After` sleep and the gate overlap in wall-clock instead of summing: the window is waited once, not twice. **Lifecycle:** timers and `AbortSignal` only, process-local; the dispatch timer is `unref()`'d where supported; `dispose()` / `[Symbol.dispose]()` clears it and rejects queued waiters with `RequestCancelled` (in-flight tasks are left to finish) — wire it through `createApp({ teardown })`. On Workers state is per-isolate so the limits bind per isolate, OTel is off so the metrics are inert, and `createWorkerHandler` accepts no `teardown`. Metrics: `mcp.pacer.queue_depth`, `mcp.pacer.wait`, `mcp.pacer.sheds`, `mcp.pacer.cooldowns`, attributed by `mcp.pacer.name` only — see `api-telemetry`. |
37
41
  | `httpStatusToErrorCode` | `(status: number) -> JsonRpcErrorCode \| undefined` | Sync status → code lookup. Returns `undefined` for 1xx/2xx/3xx. Use when you need just the code without a `Response` object handy. No status maps to `InternalError` — that code means *this* server failed, which a remote status cannot establish; every 5xx is `ServiceUnavailable` (or `Timeout` for 504) and so picks up `withRetry`'s default transient policy. |
38
42
 
39
43
  ---
@@ -109,7 +109,7 @@ In-process sliding window rate limiter with LRU eviction and OTEL span annotatio
109
109
  new RateLimiter(config: AppConfig, logger: Logger)
110
110
  ```
111
111
 
112
- Reads initial settings from `AppConfig`. Call `configure()` to override at runtime.
112
+ Reads initial settings from `AppConfig`. Call `configure()` to override at runtime — including `maxTrackedKeys`, which is a memory ceiling and is enforced during that call rather than after subsequent key churn.
113
113
 
114
114
  ### Configuration
115
115
 
@@ -131,7 +131,7 @@ Defaults: `windowMs` 15 min, `maxRequests` 100, `cleanupInterval` 5 min, `maxTra
131
131
 
132
132
  | Method | Signature | Notes |
133
133
  |:-------|:----------|:------|
134
- | `configure` | `(config: Partial<RateLimitConfig>) -> void` | Merges partial config; restarts cleanup timer if `cleanupInterval` changed |
134
+ | `configure` | `(config: Partial<RateLimitConfig>) -> void` | Merges partial config; restarts cleanup timer if `cleanupInterval` changed. Lowering `maxTrackedKeys` below the current tracked-key count trims the map to the new cap synchronously, before the call returns — one pass, expired windows dropped before live entries, live entries taken in least-recently-used order, survivors keeping their counts and reset times. Raising it, or restating a value at or above the current size, trims nothing. |
135
135
  | `check` | `(key, context?) -> void` | Throws `McpError(RateLimited)` with data `{ waitTimeSeconds, key, limit, windowMs }` when exceeded; annotates active OTEL span |
136
136
  | `getStatus` | `(key) -> { current, limit, remaining, resetTime } \| null` | Does NOT apply `keyGenerator` — pass the already-resolved key |
137
137
  | `getConfig` | `() -> RateLimitConfig` | Shallow copy of effective config |
@@ -4,7 +4,7 @@ description: >
4
4
  Design the tool surface, resources, and service layer for a new MCP server. Use when starting a new server, planning a major feature expansion, or when the user describes a domain/API they want to expose via MCP. Produces a design doc at docs/design.md that drives implementation.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.26"
7
+ version: "2.27"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -307,6 +307,20 @@ nctIds: z.union([z.string(), z.array(z.string()).max(5)])
307
307
  .describe('A single NCT ID (e.g., "NCT12345678") or an array of up to 5 NCT IDs to fetch.'),
308
308
  ```
309
309
 
310
+ **Input-edge normalization.** For every identifier, code, or enum-ish input, enumerate at design time the variants a caller will plausibly send, and decide per variant: normalize, or error. The rule is **normalize what is certain, error on what is ambiguous.** A variant is certain when the mapping is unambiguous, one-to-one, and preserves the submitted meaning exactly — then the call succeeds instead of returning a miss for a value the server could resolve. Everything short of that is an error naming the expected shape, with a `recovery` routing to the reference tool. Never fuzzy-match a value, broaden a query, swap one entity for another, or drop a filter to make a call succeed: plausible-looking rows from a guessed input are worse than a rejection the agent can act on.
311
+
312
+ | Class | Example | Handling |
313
+ |:---|:---|:---|
314
+ | Case or bare-leaf shorthand of a code | `ACS5`, `acs5` → `acs/acs5` | Normalize before lookup |
315
+ | Domain value alias | `mph` → `m/h`, `kph` → `km/h` | Alias table at the input edge |
316
+ | Composite identifier completed by context | `part: "52"` + `section: "21"` → `52.21` | Try as-given first, retry the composed form on a miss — never rewrite unconditionally, since the bare form can be legitimate |
317
+ | Delimiter-joined list where an array is accepted | `"US,JP,KR"` → `["US","JP","KR"]` | Split on the documented separator |
318
+ | Spelled-out vs. abbreviated name | `"Houston, Texas"` → `"Houston, TX"` | Normalize against the bundled name table |
319
+
320
+ These are **value**-level, and the mappings are domain knowledge — settle them per input in the design doc's param table. Argument **key** names are not: the framework drops client-added root keys and rewrites declared and case-style key aliases before the schema sees the arguments, and repairs a JSON-stringified array against the tool's own schema after a failed parse. Don't re-implement any of that per server — see `add-tool` § *Three things the framework fixes before the schema sees the arguments*.
321
+
322
+ This resolves one submitted value to one canonical value, and does not loosen the strict token match in [MCP-side list filtering](#mcp-side-list-filtering), which scores a query against many candidate names.
323
+
310
324
  #### Output design
311
325
 
312
326
  The output schema and `format` function control what the LLM reads back. Design for the agent's *next decision*, not for a UI or an API consumer. See the `add-tool` skill's **Tool Response Design** section for implementation-level patterns (partial success, empty results, metadata, context budget).
@@ -407,7 +421,7 @@ Errors are part of the tool's interface — design them during the design phase,
407
421
  | **Auth/permissions** | Insufficient scopes, expired token | `Forbidden` / `Unauthorized` | Maybe — escalate or re-auth |
408
422
  | **Server internal** | Parse failure, missing config, unexpected state | `InternalError` | No — server-side issue |
409
423
 
410
- (`InvalidParams` also exists — the SDK emits it when input fails Zod schema validation before the handler runs. Anything the handler itself throws about inputs uses `ValidationError`.)
424
+ (`InvalidParams` also exists — the framework's `parseToolArguments` emits it when input fails Zod schema validation before the handler runs. Anything the handler itself throws about inputs uses `ValidationError`.)
411
425
 
412
426
  The framework auto-classifies many of these at runtime (HTTP status codes, JS error types, common patterns), but explicit classification in the handler gives better error messages. For declared contract failures, throw via `ctx.fail('reason', …)`. For ad-hoc throws outside the contract, use error factories (`notFound()`, `validationError()`, etc.) when the code matters; plain `throw new Error()` when the framework's auto-classification is good enough.
413
427
 
@@ -647,6 +661,7 @@ Items without an `If …:` prefix apply to every design. Conditional items only
647
661
  - [ ] Tool descriptions are imperative present tense, concrete, and include operational guidance where non-obvious
648
662
  - [ ] Parameter `.describe()` text explains what the value is, what it affects, and tradeoffs
649
663
  - [ ] Input schemas use constrained types (enums, literals, regex) over free strings
664
+ - [ ] **If an input is an identifier, code, or enum-ish value:** the variants callers will plausibly send are enumerated per input — the unambiguous, one-to-one, meaning-preserving ones normalized before lookup; the rest rejected with a `recovery` naming the expected shape; any composite form tried as-given before a composed retry
650
665
  - [ ] Output schemas designed for LLM's next action — chaining IDs, post-write state, filtering communicated
651
666
  - [ ] `format()` renders all data the LLM needs — different clients forward different surfaces (Claude Code → `structuredContent`, Claude Desktop → `content[]`); both must carry the same data, not just a count or title
652
667
  - [ ] Error messages guide recovery — name what went wrong and the next tool call (no dead ends)
@@ -4,7 +4,7 @@ description: >
4
4
  Exercise tools, resources, and prompts against a live HTTP server via MCP JSON-RPC over curl. Starts the server, surfaces the catalog, runs real and adversarial inputs, measures every call (bytes, token estimate, wall-clock) and weighs the catalog, and produces a tight report with concrete findings and numbered follow-up options. Use after adding or modifying definitions, or when the user asks to test, try out, or verify their MCP surface.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.14"
7
+ version: "2.15"
8
8
  audience: external
9
9
  type: debug
10
10
  ---
@@ -402,6 +402,7 @@ Treat any hit as a `ux` finding in the report. The authoring rule lives under *T
402
402
  |:------------------------------------------------|:-------------|
403
403
  | `include` / `fields` / `expand` / `view` / `projection` parameter | Field selection: non-default value renders requested fields |
404
404
  | Array return with `query` / `filter` inputs | Empty result: does response explain *why* (echo criteria, suggest broadening)? |
405
+ | Identifier, code, or enum-ish input (an ID format, a classification code, a unit, a place name, a list the docs say may be comma-joined) | Value-variant tolerance: re-send the happy-path call with each obvious variant of that value — lowercase, the bare leaf of a hierarchical code, a common domain alias, a delimiter-joined list where an array is accepted, the spelled-out form of an abbreviated name. Pass is either outcome: the call succeeds, or it fails with an error naming the expected shape. A miss or a bare validation failure on a variant that maps one-to-one onto a valid value is a `ux` finding. Probe **values** — variants of the argument *key* name, and a JSON-stringified array as a value, are handled by the framework, not the server. |
405
406
  | Batch / bulk input (arrays of IDs, multi-item ops) | Partial success: mix valid + invalid items |
406
407
  | `annotations.readOnlyHint: true` | Confirm no mutation happened |
407
408
  | `annotations.idempotentHint: true` | Call twice with same input — safe? |
@@ -507,6 +508,7 @@ End with:
507
508
  - [ ] Every call's `⏱` line read; any happy-path response over 24,000 B with no disclosure + retrieval path filed as `ux`
508
509
  - [ ] Universal battery run on every definition (happy path, parity against the full `content[]` array, input error)
509
510
  - [ ] Situational categories applied only when triggered
511
+ - [ ] **If a tool takes an identifier, code, or enum-ish input:** that value probed with its obvious variants (lowercase, bare leaf, common alias, delimiter-joined list, spelled-out name); each either succeeded or failed with an error naming the expected shape
510
512
  - [ ] **If >15 tools:** sampled 30–40% for situational testing; skipped definitions listed in report
511
513
  - [ ] **If a tool declared an `errors: [...]` contract:** ≥1 declared failure mode triggered; `result.structuredContent.error.code` and `data.reason` verified against the contract entry
512
514
  - [ ] **If a resource declared an `errors: [...]` contract:** ≥1 declared failure mode triggered; top-level JSON-RPC `error.code` and `error.data.reason` verified against the contract entry