@cyanheads/mcp-ts-core 0.13.4 → 0.13.6

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 (108) hide show
  1. package/AGENTS.md +5 -5
  2. package/CLAUDE.md +5 -5
  3. package/README.md +1 -1
  4. package/biome.json +1 -1
  5. package/changelog/0.13.x/0.13.5.md +41 -0
  6. package/changelog/0.13.x/0.13.6.md +49 -0
  7. package/dist/linter/rules/enrichment-rules.d.ts +5 -4
  8. package/dist/linter/rules/enrichment-rules.d.ts.map +1 -1
  9. package/dist/linter/rules/enrichment-rules.js +99 -22
  10. package/dist/linter/rules/enrichment-rules.js.map +1 -1
  11. package/dist/linter/rules/error-contract-rules.d.ts +101 -3
  12. package/dist/linter/rules/error-contract-rules.d.ts.map +1 -1
  13. package/dist/linter/rules/error-contract-rules.js +291 -3
  14. package/dist/linter/rules/error-contract-rules.js.map +1 -1
  15. package/dist/linter/rules/format-parity-rules.js +1 -1
  16. package/dist/linter/rules/format-parity-rules.js.map +1 -1
  17. package/dist/linter/rules/index.d.ts +1 -1
  18. package/dist/linter/rules/index.d.ts.map +1 -1
  19. package/dist/linter/rules/index.js +1 -1
  20. package/dist/linter/rules/index.js.map +1 -1
  21. package/dist/linter/rules/resource-rules.d.ts.map +1 -1
  22. package/dist/linter/rules/resource-rules.js +5 -2
  23. package/dist/linter/rules/resource-rules.js.map +1 -1
  24. package/dist/linter/rules/tool-rules.d.ts.map +1 -1
  25. package/dist/linter/rules/tool-rules.js +5 -2
  26. package/dist/linter/rules/tool-rules.js.map +1 -1
  27. package/dist/mcp-server/handlerContext.d.ts +5 -2
  28. package/dist/mcp-server/handlerContext.d.ts.map +1 -1
  29. package/dist/mcp-server/handlerContext.js +6 -4
  30. package/dist/mcp-server/handlerContext.js.map +1 -1
  31. package/dist/mcp-server/inputRequired.d.ts +35 -2
  32. package/dist/mcp-server/inputRequired.d.ts.map +1 -1
  33. package/dist/mcp-server/inputRequired.js +116 -2
  34. package/dist/mcp-server/inputRequired.js.map +1 -1
  35. package/dist/mcp-server/resources/resource-registration.d.ts +2 -1
  36. package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
  37. package/dist/mcp-server/resources/resource-registration.js +4 -4
  38. package/dist/mcp-server/resources/resource-registration.js.map +1 -1
  39. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts +2 -1
  40. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
  41. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +5 -2
  42. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
  43. package/dist/mcp-server/server.d.ts.map +1 -1
  44. package/dist/mcp-server/server.js +11 -2
  45. package/dist/mcp-server/server.js.map +1 -1
  46. package/dist/mcp-server/tools/tool-registration.d.ts +2 -1
  47. package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
  48. package/dist/mcp-server/tools/tool-registration.js +4 -4
  49. package/dist/mcp-server/tools/tool-registration.js.map +1 -1
  50. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +14 -4
  51. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  52. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +83 -9
  53. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  54. package/dist/services/canvas/core/CanvasInstance.d.ts +4 -1
  55. package/dist/services/canvas/core/CanvasInstance.d.ts.map +1 -1
  56. package/dist/services/canvas/core/CanvasInstance.js +5 -0
  57. package/dist/services/canvas/core/CanvasInstance.js.map +1 -1
  58. package/dist/services/canvas/core/CanvasRegistry.d.ts +52 -1
  59. package/dist/services/canvas/core/CanvasRegistry.d.ts.map +1 -1
  60. package/dist/services/canvas/core/CanvasRegistry.js +79 -3
  61. package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
  62. package/dist/services/canvas/index.d.ts +1 -1
  63. package/dist/services/canvas/index.d.ts.map +1 -1
  64. package/dist/services/canvas/index.js +1 -1
  65. package/dist/services/canvas/index.js.map +1 -1
  66. package/dist/types-global/errors.d.ts +48 -0
  67. package/dist/types-global/errors.d.ts.map +1 -1
  68. package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
  69. package/dist/utils/internal/error-handler/errorHandler.js +13 -3
  70. package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
  71. package/dist/utils/internal/error-handler/mappings.d.ts +14 -1
  72. package/dist/utils/internal/error-handler/mappings.d.ts.map +1 -1
  73. package/dist/utils/internal/error-handler/mappings.js +19 -1
  74. package/dist/utils/internal/error-handler/mappings.js.map +1 -1
  75. package/dist/utils/internal/error-handler/types.d.ts +12 -1
  76. package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
  77. package/dist/utils/internal/performance.d.ts.map +1 -1
  78. package/dist/utils/internal/performance.js +4 -1
  79. package/dist/utils/internal/performance.js.map +1 -1
  80. package/dist/utils/network/fetchWithTimeout.d.ts +24 -4
  81. package/dist/utils/network/fetchWithTimeout.d.ts.map +1 -1
  82. package/dist/utils/network/fetchWithTimeout.js +10 -6
  83. package/dist/utils/network/fetchWithTimeout.js.map +1 -1
  84. package/dist/utils/network/httpError.d.ts +43 -4
  85. package/dist/utils/network/httpError.d.ts.map +1 -1
  86. package/dist/utils/network/httpError.js +53 -6
  87. package/dist/utils/network/httpError.js.map +1 -1
  88. package/dist/utils/telemetry/attributes.d.ts +8 -0
  89. package/dist/utils/telemetry/attributes.d.ts.map +1 -1
  90. package/dist/utils/telemetry/attributes.js +8 -0
  91. package/dist/utils/telemetry/attributes.js.map +1 -1
  92. package/framework-skills/add-service/SKILL.md +5 -2
  93. package/framework-skills/add-tool/SKILL.md +10 -5
  94. package/framework-skills/api-canvas/SKILL.md +31 -14
  95. package/framework-skills/api-context/SKILL.md +4 -2
  96. package/framework-skills/api-errors/SKILL.md +51 -7
  97. package/framework-skills/api-linter/SKILL.md +128 -13
  98. package/framework-skills/api-telemetry/SKILL.md +18 -3
  99. package/framework-skills/api-utils/SKILL.md +4 -4
  100. package/framework-skills/design-mcp-server/SKILL.md +4 -2
  101. package/framework-skills/field-test/SKILL.md +2 -2
  102. package/package.json +3 -3
  103. package/scripts/lint-mcp.ts +43 -4
  104. package/templates/AGENTS.md +1 -1
  105. package/templates/CLAUDE.md +1 -1
  106. package/templates/devcheck.config.json +1 -0
  107. package/templates/package.json +1 -1
  108. package/templates/src/mcp-server/tools/definitions/echo.tool.ts +7 -1
@@ -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.11"
7
+ version: "1.12"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -123,7 +123,7 @@ All custom metrics are namespaced `mcp.*` (or `process.*` / `http.client.*` wher
123
123
  |:-------|:-----|:-----|:-----------|
124
124
  | `mcp.tool.calls` | counter | `{calls}` | `mcp.tool.name`, `mcp.tool.success` |
125
125
  | `mcp.tool.duration` | histogram | `ms` | `mcp.tool.name`, `mcp.tool.success` |
126
- | `mcp.tool.errors` | counter | `{errors}` | `mcp.tool.name`, `mcp.tool.error_category` (`upstream`/`server`/`client`) |
126
+ | `mcp.tool.errors` | counter | `{errors}` | `mcp.tool.name`, `mcp.tool.error_category` (`upstream`/`server`/`client`) — see [Error category](#error-category) |
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) |
@@ -192,11 +192,26 @@ Read together: `queue_depth` rising while `wait` climbs means the configured rat
192
192
  | `mcp.sessions.active` | observable gauge | `{sessions}` | — |
193
193
  | `mcp.heartbeat.failures` | counter | `{failures}` | `mcp.connection.transport` (`stdio`/`http`) |
194
194
 
195
+ ### Error category
196
+
197
+ `mcp.tool.error_category` and `mcp.prompt.error_category` bucket a failure as `upstream` (an external dependency refused or timed out), `server` (a bug or this process's own infrastructure), or `client` (the request itself). The bucket comes from the classified JSON-RPC code, with one refinement: `RateLimited` (`-32003`) legitimately carries two sources, so the canvas tenant-cap refusal — which names itself with `data.reason: 'canvas_capacity_exhausted'` — files under `server`, and every other `-32003` stays `upstream`. Retry semantics and the HTTP 429 mapping are the same for both, which is why the code is shared and the stable `reason` discriminator does the separating.
198
+
199
+ A dashboard reading `error_category` alone therefore no longer needs to special-case one server's capacity limit as an upstream outage. `reason` itself is not on the metric — it is unbounded across a fleet, so it lives on the span and in the log.
200
+
201
+ ### Declared error severity
202
+
203
+ A definition may put `severity` on an `errors[]` entry — `debug`, `info`, `notice`, or `warning` — for an outcome it models rather than suffers. Two things move, and nothing else:
204
+
205
+ - The `Error in tool:<name>` log record is emitted at that level instead of `error`, with the same message and structured fields.
206
+ - `mcp.errors.classified` gains `mcp.error.severity` on that record. It is set only when a declared severity resolved, so a server that declares none emits exactly the series it did before.
207
+
208
+ The call still failed: the execution span keeps `SpanStatusCode.ERROR` and its recorded exception, `mcp.tool.calls` / `mcp.tool.duration` / `mcp.tool.errors` record the same values, and the completion log still reads `isSuccess: false`. Splitting those series on an authoring decision would redefine what an error rate means. Tools only — resources re-throw for the SDK to log. A cancelled request keeps its own `info`, stack-free path whatever the contract declares. See `api-errors`.
209
+
195
210
  ### Errors, rate limits, HTTP client
196
211
 
197
212
  | Metric | Type | Unit | Attributes |
198
213
  |:-------|:-----|:-----|:-----------|
199
- | `mcp.errors.classified` | counter | `{errors}` | `mcp.error.classified_code` (JSON-RPC code), `operation` |
214
+ | `mcp.errors.classified` | counter | `{errors}` | `mcp.error.classified_code` (JSON-RPC code), `operation`, and `mcp.error.severity` when the failure's declared severity resolved |
200
215
  | `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) |
201
216
  | `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) |
202
217
 
@@ -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.10"
7
+ version: "2.11"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -31,14 +31,14 @@ Utility exports from `@cyanheads/mcp-ts-core/utils`. Utilities with complex APIs
31
31
 
32
32
  | Export | API | Notes |
33
33
  |:-------|:----|:------|
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. |
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`), `errorHeaders?: string[]` (response headers copied onto `error.data.headers` on a non-2xx — same selector as `httpErrorFromResponse` below; `location` is selectable under `redirect: 'manual'` but does **not** compose with `rejectPrivateIPs`, whose per-hop branch consumes the 3xx before the throw path sees it), 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
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
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
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
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. |
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. Response headers are opt-in on the same footing: `errorHeaders: ['x-ratelimit-remaining-usd', 'x-request-id']` copies the named headers onto `data.headers` under **lowercase** keys — selection is case-insensitive and entries differing only in case collapse to one key, presence follows `Headers.has()` (an empty value is captured as `''`, an absent header adds no key), and a multi-valued field is captured comma-joined as `Headers.get()` returns it. Omitted, empty, or matching nothing, no `headers` key is emitted. `set-cookie` is **never** captured whatever the selector says: it is credential-bearing and `Headers.get()` joins its values into a string that is not a valid reconstruction. Every selected value reaches the client, so never name a header that carries a credential — and a selected `Location` can itself carry a sensitive path, query, or token. `HttpErrorFromResponseOptions`: `service?` (logical name in message, e.g. `'NCBI'`), `captureBody?` (default `true`), `bodyLimit?` (default `500`), `includeUrl?` (default `false`), `errorHeaders?` (default none), `data?` (extra fields merged into `error.data`, overriding defaults on key collision — a caller's own `url` or `headers` 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
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`. |
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. |
41
+ | `httpStatusToErrorCode` | `(status: number) -> JsonRpcErrorCode \| undefined` | Sync status → code lookup. Returns `undefined` for 1xx/2xx. A 3xx maps to `InvalidRequest` — it reaches error mapping under `redirect: 'manual'`, where the request as sent cannot be served at this URL, and that code is outside `withRetry`'s transient set since re-issuing returns the same redirect. 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. |
42
42
 
43
43
  ---
44
44
 
@@ -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.27"
7
+ version: "2.28"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -437,9 +437,11 @@ throw new Error('Not found');
437
437
  "No session working directory set. Please specify a 'path' or use 'git_set_working_dir' first."
438
438
 
439
439
  // Good — structured hint in error data using the canonical `data.recovery.hint` shape.
440
- // The framework auto-mirrors `data.recovery.hint` into the content[] text as
440
+ // The framework mirrors `data.recovery.hint` into the content[] text as
441
441
  // `Recovery: <hint>` so format()-only clients (Claude Desktop) see the same
442
442
  // guidance structuredContent clients (Claude Code) read from `error.data.recovery.hint`.
443
+ // A hint the message already contains verbatim is dropped from the text rather
444
+ // than stated twice, and stays on structuredContent either way.
443
445
  throw forbidden(
444
446
  "Cannot perform 'reset --hard' on protected branch 'main' without explicit confirmation.",
445
447
  {
@@ -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.15"
7
+ version: "2.16"
8
8
  audience: external
9
9
  type: debug
10
10
  ---
@@ -436,7 +436,7 @@ When a call surprises you — slow, hangs, returns terse output, surfaces an unh
436
436
 
437
437
  - **`content[]` is an array of blocks — read all of them, never just `content[0]`.** A success result is assembled as `[...ctx.content media blocks, ...the format()/JSON domain render, ...the enrichment trailer]`. Everything the handler put on `ctx.enrich` — empty-result notices, totals, query echoes, truncation disclosure — renders in that trailer, a **separate trailing block**, not inside the `format()` block. Quoting `content[0].text` and reporting those fields as absent from `content[]` is a false parity gap; the suggested fix (render them in `format()` too) would double-render them. Dump `.result.content` in full before claiming drift.
438
438
  - Tool domain errors return `{result: {content: [...], isError: true}}` — they live in `result`, not `error`. Check `isError`, not the JSON-RPC error field.
439
- - **Tool error code/reason** rides on `result.structuredContent.error.{code, message, data?.reason}` — inspect that, not just the text. `data` is only spread when the handler threw an `McpError` (or `ZodError`); plain `throw new Error(...)` won't populate `data.reason`. Use `ctx.fail`-thrown errors when the contract reason matters. The text in `result.content[0].text` mirrors the message and includes `Recovery: <hint>` when `data.recovery.hint` is present.
439
+ - **Tool error code/reason** rides on `result.structuredContent.error.{code, message, data?.reason}` — inspect that, not just the text. `data` is only spread when the handler threw an `McpError` (or `ZodError`); plain `throw new Error(...)` won't populate `data.reason`. Use `ctx.fail`-thrown errors when the contract reason matters. The text in `result.content[0].text` mirrors the message, adds `Recovery: <hint>` when `data.recovery.hint` says something the message does not already say, and closes with `(reason <reason> · not retryable)` for whichever of `data.reason` / `data.retryable` is present — the numeric code stays JSON-only.
440
440
  - **Resource errors** are JSON-RPC-level — they appear in the top-level `error.{code, data.reason}` field, not inside `result`. Resource handlers re-throw rather than producing an `isError` envelope.
441
441
  - JSON-RPC `error` only appears for protocol issues (bad session, malformed envelope, unknown method).
442
442
  - `mcp_call` already strips SSE framing. Pipe to `jq` for readability.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyanheads/mcp-ts-core",
3
- "version": "0.13.4",
3
+ "version": "0.13.6",
4
4
  "mcpName": "io.github.cyanheads/mcp-ts-core",
5
5
  "description": "Agent-native TypeScript framework for MCP servers. Includes runtime infrastructure and agent skills for building, testing, and shipping servers.",
6
6
  "files": [
@@ -197,7 +197,7 @@
197
197
  "publish-mcp": "mcp-publisher login github -token \"$(security find-generic-password -a \"$USER\" -s mcp-publisher-github-pat -w)\" && mcp-publisher publish"
198
198
  },
199
199
  "devDependencies": {
200
- "@biomejs/biome": "2.5.13",
200
+ "@biomejs/biome": "2.5.14",
201
201
  "@cloudflare/vitest-pool-workers": "^0.22.0",
202
202
  "@cloudflare/workers-types": "5.20260910.1",
203
203
  "@duckdb/node-api": "^1.5.5-r.5",
@@ -228,7 +228,7 @@
228
228
  "depcheck": "^1.4.7",
229
229
  "diff": "^9.0.0",
230
230
  "execa": "^10.0.1",
231
- "fast-check": "^4.10.0",
231
+ "fast-check": "^4.10.1",
232
232
  "fast-xml-parser": "^5.11.1",
233
233
  "ignore": "^7.0.9",
234
234
  "js-yaml": "^5.4.2",
@@ -14,10 +14,14 @@
14
14
  *
15
15
  * Runtime-agnostic: works with bun, tsx, and Node.js (via ts-node/esm).
16
16
  *
17
+ * Rule knobs come from the project's `devcheck.config.json` `lint` block, so one
18
+ * declaration covers every entrypoint that shells out to this script.
19
+ *
17
20
  * @module scripts/lint-mcp
18
21
  */
19
22
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
20
23
  import { join, resolve } from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
21
25
 
22
26
  // ---------------------------------------------------------------------------
23
27
  // Import validateDefinitions — resolve from package or local source
@@ -111,6 +115,38 @@ function tryReadJson(path: string): unknown {
111
115
  }
112
116
  }
113
117
 
118
+ /** The `lint` block of `devcheck.config.json`, as far as this script reads it. */
119
+ interface LintConfig {
120
+ lint?: { truncationAllowlist?: unknown };
121
+ }
122
+
123
+ /** The `LintInput` knobs a project declares in `devcheck.config.json`. */
124
+ export interface LintOptions {
125
+ truncationAllowlist?: ReadonlyArray<string> | false;
126
+ }
127
+
128
+ /**
129
+ * Reads `lint.truncationAllowlist` from the project's `devcheck.config.json`.
130
+ *
131
+ * An absent or unreadable key yields `{}`, which leaves `validateDefinitions()`
132
+ * to fall back to `MCP_LINT_TRUNCATION_ALLOWLIST`. A declared value is passed as
133
+ * `LintInput.truncationAllowlist` and therefore wins over that env var — a
134
+ * reviewed, version-controlled exemption is not something ambient environment
135
+ * should override.
136
+ */
137
+ export function readLintOptions(configPath = resolve('devcheck.config.json')): LintOptions {
138
+ const allowlist = (tryReadJson(configPath) as LintConfig | undefined)?.lint?.truncationAllowlist;
139
+ if (allowlist === undefined) return {};
140
+ if (allowlist === false) return { truncationAllowlist: false };
141
+ if (Array.isArray(allowlist) && allowlist.every((name) => typeof name === 'string')) {
142
+ return { truncationAllowlist: allowlist as string[] };
143
+ }
144
+ console.warn(
145
+ `Warning: ${configPath} "lint.truncationAllowlist" must be an array of tool names or false — ignoring it.`,
146
+ );
147
+ return {};
148
+ }
149
+
114
150
  async function main(): Promise<void> {
115
151
  const files = discoverFiles();
116
152
 
@@ -163,6 +199,7 @@ async function main(): Promise<void> {
163
199
  prompts,
164
200
  serverJson,
165
201
  ...(packageJson ? { packageJson } : {}),
202
+ ...readLintOptions(),
166
203
  });
167
204
 
168
205
  for (const w of report.warnings) {
@@ -187,7 +224,9 @@ async function main(): Promise<void> {
187
224
  }
188
225
  }
189
226
 
190
- main().catch((err) => {
191
- console.error('lint-mcp failed:', err);
192
- process.exit(1);
193
- });
227
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
228
+ await main().catch((err) => {
229
+ console.error('lint-mcp failed:', err);
230
+ process.exit(1);
231
+ });
232
+ }
@@ -214,7 +214,7 @@ Handlers receive a unified `ctx` object. Key properties:
214
214
 
215
215
  Handlers throw — the framework catches, classifies, and formats.
216
216
 
217
- **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. Pass `ctx.recoveryFor('reason')` as the throw's data to put it on the wire (`data.recovery.hint`, mirrored into `content[]` text); override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
217
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. Pass `ctx.recoveryFor('reason')` as the throw's data to put it on the wire (`data.recovery.hint`, mirrored into `content[]` text unless the message already contains it verbatim); override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Forwarding it is lint-enforced per throw site (`error-contract-recovery-unforwarded`). Mark an entry the service layer throws with `thrownBy: 'service'` so `error-contract-unthrown` skips it — lint-only metadata, nothing at runtime reads it. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
218
218
 
219
219
  ```ts
220
220
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
@@ -214,7 +214,7 @@ Handlers receive a unified `ctx` object. Key properties:
214
214
 
215
215
  Handlers throw — the framework catches, classifies, and formats.
216
216
 
217
- **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. Pass `ctx.recoveryFor('reason')` as the throw's data to put it on the wire (`data.recovery.hint`, mirrored into `content[]` text); override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
217
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. Pass `ctx.recoveryFor('reason')` as the throw's data to put it on the wire (`data.recovery.hint`, mirrored into `content[]` text unless the message already contains it verbatim); override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Forwarding it is lint-enforced per throw site (`error-contract-recovery-unforwarded`). Mark an entry the service layer throws with `thrownBy: 'service'` so `error-contract-unthrown` skips it — lint-only metadata, nothing at runtime reads it. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
218
218
 
219
219
  ```ts
220
220
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
@@ -14,6 +14,7 @@
14
14
  "outdated": {
15
15
  "allowlist": []
16
16
  },
17
+ "lint": {},
17
18
  "packaging": {
18
19
  "pluginManifests": true
19
20
  }
@@ -66,7 +66,7 @@
66
66
  "zod": "{{ZOD_VERSION}}"
67
67
  },
68
68
  "devDependencies": {
69
- "@biomejs/biome": "2.5.13",
69
+ "@biomejs/biome": "2.5.14",
70
70
  "@socketsecurity/bun-security-scanner": "^1.1.2",
71
71
  "@types/node": "26.5.1",
72
72
  "@vitest/coverage-istanbul": "4.1.11",
@@ -42,7 +42,13 @@ export const echoTool = tool('template_echo_message', {
42
42
 
43
43
  handler(input, ctx) {
44
44
  if (input.message.trim().length === 0) {
45
- throw ctx.fail('empty_message', 'Message must contain at least one non-whitespace character.');
45
+ // Spreading ctx.recoveryFor puts the declared recovery on the wire. Without
46
+ // it the hint reaches neither structuredContent nor content[].
47
+ throw ctx.fail(
48
+ 'empty_message',
49
+ 'Message must contain at least one non-whitespace character.',
50
+ { ...ctx.recoveryFor('empty_message') },
51
+ );
46
52
  }
47
53
  // Reaches both client surfaces with no format() plumbing.
48
54
  ctx.enrich({ characterCount: input.message.length });