@cyanheads/mcp-ts-core 0.13.12 → 0.13.13

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 (93) hide show
  1. package/AGENTS.md +7 -6
  2. package/CLAUDE.md +7 -6
  3. package/README.md +1 -1
  4. package/changelog/0.13.x/0.13.13.md +93 -0
  5. package/dist/core/context.d.ts +12 -0
  6. package/dist/core/context.d.ts.map +1 -1
  7. package/dist/core/context.js +59 -14
  8. package/dist/core/context.js.map +1 -1
  9. package/dist/core/worker.d.ts.map +1 -1
  10. package/dist/core/worker.js +21 -8
  11. package/dist/core/worker.js.map +1 -1
  12. package/dist/mcp-server/inputRequired.d.ts +18 -9
  13. package/dist/mcp-server/inputRequired.d.ts.map +1 -1
  14. package/dist/mcp-server/inputRequired.js +29 -15
  15. package/dist/mcp-server/inputRequired.js.map +1 -1
  16. package/dist/mcp-server/prompts/prompt-registration.d.ts.map +1 -1
  17. package/dist/mcp-server/prompts/prompt-registration.js +10 -7
  18. package/dist/mcp-server/prompts/prompt-registration.js.map +1 -1
  19. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
  20. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +22 -9
  21. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
  22. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +7 -1
  23. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +34 -19
  25. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  26. package/dist/mcp-server/transports/auth/lib/authUtils.d.ts +3 -1
  27. package/dist/mcp-server/transports/auth/lib/authUtils.d.ts.map +1 -1
  28. package/dist/mcp-server/transports/auth/lib/authUtils.js +4 -2
  29. package/dist/mcp-server/transports/auth/lib/authUtils.js.map +1 -1
  30. package/dist/storage/providers/fileSystem/fileSystemProvider.d.ts.map +1 -1
  31. package/dist/storage/providers/fileSystem/fileSystemProvider.js +48 -21
  32. package/dist/storage/providers/fileSystem/fileSystemProvider.js.map +1 -1
  33. package/dist/types-global/errors.d.ts.map +1 -1
  34. package/dist/types-global/errors.js +31 -16
  35. package/dist/types-global/errors.js.map +1 -1
  36. package/dist/utils/internal/error-handler/errorHandler.d.ts +29 -12
  37. package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
  38. package/dist/utils/internal/error-handler/errorHandler.js +198 -100
  39. package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
  40. package/dist/utils/internal/error-handler/helpers.d.ts +75 -4
  41. package/dist/utils/internal/error-handler/helpers.d.ts.map +1 -1
  42. package/dist/utils/internal/error-handler/helpers.js +232 -33
  43. package/dist/utils/internal/error-handler/helpers.js.map +1 -1
  44. package/dist/utils/internal/error-handler/types.d.ts +17 -7
  45. package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
  46. package/dist/utils/internal/logValue.d.ts +33 -0
  47. package/dist/utils/internal/logValue.d.ts.map +1 -0
  48. package/dist/utils/internal/logValue.js +539 -0
  49. package/dist/utils/internal/logValue.js.map +1 -0
  50. package/dist/utils/internal/logger.d.ts +38 -9
  51. package/dist/utils/internal/logger.d.ts.map +1 -1
  52. package/dist/utils/internal/logger.js +228 -118
  53. package/dist/utils/internal/logger.js.map +1 -1
  54. package/dist/utils/internal/performance.d.ts.map +1 -1
  55. package/dist/utils/internal/performance.js +9 -11
  56. package/dist/utils/internal/performance.js.map +1 -1
  57. package/dist/utils/internal/requestContext.d.ts +3 -3
  58. package/dist/utils/internal/requestContext.js +1 -1
  59. package/dist/utils/network/fetchWithTimeout.d.ts +18 -10
  60. package/dist/utils/network/fetchWithTimeout.d.ts.map +1 -1
  61. package/dist/utils/network/fetchWithTimeout.js +48 -20
  62. package/dist/utils/network/fetchWithTimeout.js.map +1 -1
  63. package/dist/utils/network/httpError.d.ts +8 -6
  64. package/dist/utils/network/httpError.d.ts.map +1 -1
  65. package/dist/utils/network/httpError.js +23 -7
  66. package/dist/utils/network/httpError.js.map +1 -1
  67. package/dist/utils/network/retry.d.ts +7 -4
  68. package/dist/utils/network/retry.d.ts.map +1 -1
  69. package/dist/utils/network/retry.js +24 -10
  70. package/dist/utils/network/retry.js.map +1 -1
  71. package/dist/utils/security/sanitization.d.ts +24 -28
  72. package/dist/utils/security/sanitization.d.ts.map +1 -1
  73. package/dist/utils/security/sanitization.js +25 -84
  74. package/dist/utils/security/sanitization.js.map +1 -1
  75. package/dist/utils/security/sensitiveFields.d.ts +32 -4
  76. package/dist/utils/security/sensitiveFields.d.ts.map +1 -1
  77. package/dist/utils/security/sensitiveFields.js +85 -4
  78. package/dist/utils/security/sensitiveFields.js.map +1 -1
  79. package/dist/utils/telemetry/trace.d.ts +3 -1
  80. package/dist/utils/telemetry/trace.d.ts.map +1 -1
  81. package/dist/utils/telemetry/trace.js +6 -6
  82. package/dist/utils/telemetry/trace.js.map +1 -1
  83. package/framework-skills/api-config/SKILL.md +2 -1
  84. package/framework-skills/api-context/SKILL.md +3 -3
  85. package/framework-skills/api-errors/SKILL.md +17 -15
  86. package/framework-skills/api-linter/SKILL.md +11 -10
  87. package/framework-skills/api-telemetry/SKILL.md +6 -4
  88. package/framework-skills/api-utils/SKILL.md +6 -6
  89. package/framework-skills/api-utils/references/security.md +4 -2
  90. package/package.json +6 -5
  91. package/scripts/check-framework-antipatterns.ts +3 -2
  92. package/templates/.env.example +2 -0
  93. package/templates/tests/tools/echo.tool.test.ts +19 -1
@@ -1 +1 @@
1
- {"version":3,"file":"sensitiveFields.js","sourceRoot":"","sources":["../../../src/utils/security/sensitiveFields.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,8EAA8E;AAC9E,MAAM,CAAC,MAAM,wBAAwB,GAAsB;IACzD,UAAU;IACV,OAAO;IACP,QAAQ;IACR,QAAQ;IACR,YAAY;IACZ,KAAK;IACL,KAAK;IACL,KAAK;IACL,eAAe;IACf,QAAQ;IACR,cAAc;IACd,eAAe;IACf,aAAa;IACb,YAAY;CACb,CAAC;AAEF;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAAC,MAAyB;IACzD,OAAO,MAAM,CAAC,OAAO,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,KAAK,EAAE,KAAK,KAAK,EAAE,EAAE,OAAO,KAAK,EAAE,CAAC,CAAC,CAAC;AAC1E,CAAC"}
1
+ {"version":3,"file":"sensitiveFields.js","sourceRoot":"","sources":["../../../src/utils/security/sensitiveFields.ts"],"names":[],"mappings":"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"}
@@ -95,7 +95,9 @@ export declare function injectCurrentContextInto<T extends Record<string, unknow
95
95
  /**
96
96
  * Creates a new span for manual instrumentation with automatic error handling.
97
97
  * The span is automatically marked as OK on success or ERROR on exception.
98
- * Errors are recorded as exceptions and automatically propagated.
98
+ * Errors are recorded as exceptions (any other thrown value as an `Error` of its
99
+ * string form) and propagated as thrown — a field that cannot be read, such as
100
+ * a `message` getter that throws, is written `'[Unreadable]'` on the span.
99
101
  *
100
102
  * @param operationName - Name of the span (e.g., 'database.query', 'external.api')
101
103
  * @param fn - Async function to execute within the span
@@ -1 +1 @@
1
- {"version":3,"file":"trace.d.ts","sourceRoot":"","sources":["../../../src/utils/telemetry/trace.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAIL,KAAK,IAAI,EAKV,MAAM,oBAAoB,CAAC;AAG5B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oCAAoC,CAAC;AAGzE;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,oFAAoF;IACpF,OAAO,EAAE,OAAO,CAAC;IACjB,sFAAsF;IACtF,MAAM,EAAE,MAAM,CAAC;IACf,6FAA6F;IAC7F,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,CAAC,EAAE,cAAc,GAAG,MAAM,GAAG,SAAS,CAMzE;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GACpD,eAAe,GAAG,SAAS,CAc7B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,4BAA4B,CAC1C,aAAa,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,EAC3D,SAAS,EAAE,MAAM,GAChB,cAAc,CAWhB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,wBAAwB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,EAAE,CAAC,GAAG,CAAC,CAGzF;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,QAAQ,CAAC,CAAC,EAC9B,aAAa,EAAE,MAAM,EACrB,EAAE,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,EAC9B,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,GACrD,OAAO,CAAC,CAAC,CAAC,CA0BZ;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,GAAG,EAAE,cAAc,GAAG,SAAS,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAY/E;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAE7C"}
1
+ {"version":3,"file":"trace.d.ts","sourceRoot":"","sources":["../../../src/utils/telemetry/trace.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAIL,KAAK,IAAI,EAKV,MAAM,oBAAoB,CAAC;AAI5B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oCAAoC,CAAC;AAGzE;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,oFAAoF;IACpF,OAAO,EAAE,OAAO,CAAC;IACjB,sFAAsF;IACtF,MAAM,EAAE,MAAM,CAAC;IACf,6FAA6F;IAC7F,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,CAAC,EAAE,cAAc,GAAG,MAAM,GAAG,SAAS,CAMzE;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GACpD,eAAe,GAAG,SAAS,CAc7B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,4BAA4B,CAC1C,aAAa,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,EAC3D,SAAS,EAAE,MAAM,GAChB,cAAc,CAWhB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,wBAAwB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,EAAE,CAAC,GAAG,CAAC,CAGzF;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAsB,QAAQ,CAAC,CAAC,EAC9B,aAAa,EAAE,MAAM,EACrB,EAAE,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,EAC9B,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,GACrD,OAAO,CAAC,CAAC,CAAC,CAuBZ;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,GAAG,EAAE,cAAc,GAAG,SAAS,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAY/E;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAE7C"}
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { context as otContext, propagation, ROOT_CONTEXT, SpanStatusCode, TraceFlags, trace, } from '@opentelemetry/api';
8
8
  import { config } from '../../config/index.js';
9
+ import { asError, recordSpanFailure } from '../internal/error-handler/helpers.js';
9
10
  import { requestContextService } from '../internal/requestContext.js';
10
11
  /**
11
12
  * Builds a W3C `traceparent` header value from a `RequestContext` or the currently active span.
@@ -117,7 +118,9 @@ export function injectCurrentContextInto(carrier) {
117
118
  /**
118
119
  * Creates a new span for manual instrumentation with automatic error handling.
119
120
  * The span is automatically marked as OK on success or ERROR on exception.
120
- * Errors are recorded as exceptions and automatically propagated.
121
+ * Errors are recorded as exceptions (any other thrown value as an `Error` of its
122
+ * string form) and propagated as thrown — a field that cannot be read, such as
123
+ * a `message` getter that throws, is written `'[Unreadable]'` on the span.
121
124
  *
122
125
  * @param operationName - Name of the span (e.g., 'database.query', 'external.api')
123
126
  * @param fn - Async function to execute within the span
@@ -146,11 +149,8 @@ export async function withSpan(operationName, fn, attributes) {
146
149
  return result;
147
150
  }
148
151
  catch (error) {
149
- span.recordException(error instanceof Error ? error : new Error(String(error)));
150
- span.setStatus({
151
- code: SpanStatusCode.ERROR,
152
- message: error instanceof Error ? error.message : String(error),
153
- });
152
+ // Guarded: a value whose fields throw on read must leave here as itself (#697).
153
+ recordSpanFailure(span, asError(error));
154
154
  throw error;
155
155
  }
156
156
  finally {
@@ -1 +1 @@
1
- {"version":3,"file":"trace.js","sourceRoot":"","sources":["../../../src/utils/telemetry/trace.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"trace.js","sourceRoot":"","sources":["../../../src/utils/telemetry/trace.ts"],"names":[],"mappings":"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"}
@@ -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.24"
7
+ version: "1.25"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -96,6 +96,7 @@ await createApp({ sessionMode: { default: 'stateful', require: 'stateful' } });
96
96
  | `LOGS_DIR` | `logsPath` | `<app-root>/logs` | Node.js only; absolute paths are used verbatim, relative ones resolve against the application root (see Core config) — never the framework's install directory. A file under it that cannot be opened (read-only mount, another user's directory) is dropped at startup with one `warning` naming it and the error code; stderr and the other files keep logging |
97
97
  | `LOG_TOOL_FAILURE_PAYLOADS` | `logToolFailurePayloads` | `false` | Opt-in. Each failed tool call also writes a `Tool failure payload: <tool>` record carrying `toolInput` (the arguments as sent) and `toolResult` (the `CallToolResult` returned) as redacted JSON strings, at the call's error-record level. Reaches every log destination — stderr, `combined.log`, and OTLP when `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` is set. Redaction is by key name only, so a secret inside a free-form value (a query, a message) is logged. Record shape: `api-telemetry` Logs |
98
98
  | `LOG_TOOL_FAILURE_PAYLOAD_MAX_BYTES` | `logToolFailurePayloadMaxBytes` | `16384` | Cap per payload, in UTF-8 bytes. A longer one is cut on a character boundary and flagged with `toolInputTruncated` / `toolResultTruncated` |
99
+ | `LOG_LLM_INTERACTIONS` | `logLlmInteractions` | `false` | Opt-in. The OpenRouter provider (`/services`) writes each chat completion's full request and response bodies to `interactions.log` instead of metadata (model, message count, generation params, finish reasons, token usage). Transcripts can carry user PII, secrets, and confidential prompts, and redaction is by key name only. `interactions.log` sits under `LOGS_DIR` (Node.js only) and is never exported over OTLP |
99
100
 
100
101
  ### Transport
101
102
 
@@ -4,7 +4,7 @@ description: >
4
4
  Canonical reference for the unified `Context` object passed to every tool and resource handler in `@cyanheads/mcp-ts-core`. Covers the full interface, its `RequestContext` base, all sub-APIs (`ctx.log`, `ctx.state`, `ctx.requestInput`, `ctx.inputs`, `ctx.clientCapabilities`, `ctx.enrich`, `ctx.content`), and when to use each.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.11"
7
+ version: "2.12"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -154,7 +154,7 @@ Never re-open the shape to get past a type error: no index signature, no widenin
154
154
 
155
155
  Request-scoped structured logger. Every log line is automatically annotated with `requestId`, `traceId`, and `tenantId` — no manual spreading needed.
156
156
 
157
- **Dual-sink.** Each call writes to Pino *and* mirrors onto the MCP wire as a `notifications/message` (the framework advertises the `logging` capability). One level check gates both sinks: `MCP_LOG_LEVEL` (or a runtime `logger.setLevel()`) is a floor for the client stream as well as the process log, compared on the RFC 5424 order, so a `notice` floor drops `info` from both. The SDK then filters by the client's own level — `logging/setLevel`, or the `io.modelcontextprotocol/logLevel` a 2026-07-28 request carries — which can only narrow the floor, never widen it. The wire payload is `{ message, ...data }` with every sensitive field masked as `[REDACTED]` at any depth: the same field list the logs are redacted with, extensible through `sanitization.setSensitiveFields`, matched as `sanitizeForLogging` matches it — case- and separator-insensitive (`API_KEY` is `apiKey`), and on any one word of a compound name, so `accessToken` (and `tokenCount`) are masked. The caller's `data` object is never modified. `ctx.log.error` adds `error: <message>`. `message` and `error` are reserved wire keys, written after `data`: a `message` in `data` never replaces the log line on the wire, and on `ctx.log.error` with an `Error` the `error` key is always that error's message. The process log line still carries the caller's own fields, except one reusing a canonical name the context already sets (`requestId`, `traceId`, `spanId`, `tenantId`, …) — there the context's value wins, so the line stays correlated to its request. Delivery is fire-and-forget — a client that never upgraded to SSE, set a higher level, or already disconnected drops the notification, and a failed send never fails the handler. Treat `ctx.log` as client-visible: it is no longer a server-only sink, so don't log anything there you wouldn't put in a tool result.
157
+ **Dual-sink.** Each call writes to Pino *and* mirrors onto the MCP wire as a `notifications/message` (the framework advertises the `logging` capability). One level check gates both sinks: `MCP_LOG_LEVEL` (or a runtime `logger.setLevel()`) is a floor for the client stream as well as the process log, compared on the RFC 5424 order, so a `notice` floor drops `info` from both. The SDK then filters by the client's own level — `logging/setLevel`, or the `io.modelcontextprotocol/logLevel` a 2026-07-28 request carries — which can only narrow the floor, never widen it. The wire payload is `{ message, ...data }` with every sensitive field masked as `[REDACTED]` at any depth: the same field list and matcher the logs are redacted with, extensible through `sanitization.setSensitiveFields`. A key is masked when some run of its adjacent words, joined, equals a sensitive name, case and separators ignored — words split at every character other than a letter or digit, at a lowercase letter followed by a capital, at the end of a run of capitals, and around each run of digits — so `API_KEY`, `x-api-key`, `accessToken`, `apiKey2`, and `tokenCount` are masked while `max_tokens`, `MAX_TOKENS`, and `tokenizer` are not. An `Error` in `data`, at any depth, goes on the wire as `{ type, message }` only — no stack, cause, or other own property such as a request URL; the process log writes it in full (`api-telemetry` Logs). The mirror keeps the process log's bounds: objects through 15 levels below the data root, one 16 levels down as `'[MaxDepth]'`, repeated content (an object or `toJSON()` result reached again, or a string of 1,024+ characters written again) charged about its written size against 1,000,000 characters and cut with `'[Truncated]'`, at most 400,000 reads a walk, one per object, field, and array element (so data a getter or Proxy builds on every read is cut too), at most 16 MiB (16,777,216 characters) of strings, field names, and primitives a walk writes, repeated or not, then `'[Truncated]'` and nothing more, a reference back to an enclosing object as `'[Circular]'`, and a value whose read throws (a getter, a Proxy trap, a `toJSON`) as `'[Unreadable]'`, with `data` that cannot be read at all (a revoked Proxy) written as `data: '[Unreadable]'` on both sinks — so no value in `data` can stall the handler or fail it. The caller's `data` object is never modified. `ctx.log.error` adds `error: <message>`. `message` and `error` are reserved wire keys, written after `data`: a `message` in `data` never replaces the log line on the wire, and on `ctx.log.error` with an `Error` the `error` key is always that error's message as a string — a Symbol as `'Symbol(…)'`, a number as its digits, and `'[Unreadable]'` when reading it throws or it is an object — and the notification is still sent. The process log line still carries the caller's own fields, except one reusing a canonical name the context already sets (`requestId`, `traceId`, `spanId`, `tenantId`, …) — there the context's value wins, so the line stays correlated to its request — and one named after a field the logger writes on the line itself (`level`, `time`, `msg`, `env`, `version`, `pid`, `hostname`, and `err` on `ctx.log.error` with an `Error`), which is written as `data_<name>` so the line keeps its own `level` and `version`. Delivery is fire-and-forget — a client that never upgraded to SSE, set a higher level, or already disconnected drops the notification, and a failed send never fails the handler. Treat `ctx.log` as client-visible: it is no longer a server-only sink, so don't log anything there you wouldn't put in a tool result.
158
158
 
159
159
  ### Methods
160
160
 
@@ -332,7 +332,7 @@ Always present, on every transport and both protocol eras. A handler that needs
332
332
 
333
333
  One code path serves both eras. A 2026-07-28 client fulfils the embedded requests and retries the call; for a 2025-era session the SDK's legacy shim fulfils the same returns by issuing real `elicitation/create` / `sampling/createMessage` / `roots/list` round trips and re-entering the handler itself.
334
334
 
335
- **A 2025-era client that declared no matching capability is refused, with an envelope.** URL-mode elicitation needs `elicitation.url`, form-mode needs `elicitation.form` (a bare `elicitation: {}` satisfies it), sampling needs `sampling` — `sampling.tools` when the request carries `tools` / `toolChoice` — and `roots/list` needs `roots`. `ctx.requestInput` runs the check on the result it builds and throws the refusal instead of the signal, so it never reaches the wire and the handler fails where it stands — the execution measurement records it as a failed call, its `Error in tool:<name>` record logs at `notice` (a property of the client's connection, not a server fault), and each family's usual error path shapes it. A tool gets `isError` with `structuredContent.error.code = -32600` (`InvalidRequest`), `data.reason: 'client_capability_missing'`, and a `data.recovery.hint` naming the capability; a resource read gets the same code, reason, and hint through the JSON-RPC error envelope. A prompt's `generate` receives no `ctx`, so it has no `ctx.requestInput` to gate. The check runs on every round, so a handler that elicits first and samples second is gated again on the second. A return carrying only `requestState` asks the client for nothing and is never gated. On the 2026-07-28 leg the SDK owns this check and a violation surfaces as its `MissingRequiredClientCapabilityError` (`-32021`) instead.
335
+ **A 2025-era client that declared no matching capability is refused, with an envelope.** URL-mode elicitation needs `elicitation.url`, form-mode needs `elicitation.form` (a bare `elicitation: {}` satisfies it), sampling needs `sampling` — `sampling.tools` when the request carries `tools` / `toolChoice` — and `roots/list` needs `roots`. `ctx.requestInput` runs the check on the result it builds and throws the refusal instead of the signal, so it never reaches the wire and the handler fails where it stands — the execution measurement records it as a failed call, its `Error in tool:<name>` record logs at `notice` with no stack (a property of the client's connection, not a server fault), and each family's usual error path shapes it. A tool gets `isError` with `structuredContent.error.code = -32600` (`InvalidRequest`), `data.reason: 'client_capability_missing'`, and a `data.recovery.hint` naming the capability; a resource read gets the same code, reason, and hint through the JSON-RPC error envelope. A prompt's `generate` receives no `ctx`, so it has no `ctx.requestInput` to gate. The check runs on every round, so a handler that elicits first and samples second is gated again on the second. A return carrying only `requestState` asks the client for nothing and is never gated. On the 2026-07-28 leg the SDK owns this check and a violation surfaces as its `MissingRequiredClientCapabilityError` (`-32021`) instead.
336
336
 
337
337
  **The refusal's hint ends at reconnecting** — ``Reconnect with a client that declares the `elicitation.form` capability.`` — and offers no other way to supply the answer, because a consent gate deliberately has no input field for it: the model would fill it in. A handler whose own arguments can stand in for the answer says so per call with the optional second argument, a sentence appended to the hint after a space:
338
338
 
@@ -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.20"
7
+ version: "1.21"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -63,7 +63,7 @@ export const fetchTool = tool('fetch_articles', {
63
63
  | Surface | Behavior |
64
64
  |:--------|:---------|
65
65
  | Compile time | `ctx.fail('typo')` is a TS error. Auto-completes declared reasons. |
66
- | Runtime | `ctx.fail(reason, msg?, data?, options?)` builds an `McpError(contract.code, msg, { ...data, reason }, options)` — `data.reason` is auto-populated from the contract and cannot be overridden by caller-supplied data (spread first, then `reason` written last), so observers see a stable identifier. `options` accepts `{ cause }` for ES2022 error chaining. |
66
+ | Runtime | `ctx.fail(reason, msg?, data?, options?)` builds an `McpError(contract.code, msg, { ...data, reason }, options)` — `data.reason` is auto-populated from the contract and cannot be overridden by caller-supplied data (spread first, then `reason` written last), so observers see a stable identifier. `options` accepts `{ cause }` for ES2022 error chaining. Its stack starts at the line that called `ctx.fail`, with the framework's own frame cut, as an error factory's does. |
67
67
  | Runtime (recovery) | A failure whose `data.reason` names a declared entry and carries no `data.recovery` gets `data.recovery.hint` set to the entry's `recovery` at the handler boundary — see below. |
68
68
  | Lint (devcheck) | Each `code` validated against `JsonRpcErrorCode`. Reasons validated as snake_case + unique within contract. `recovery` validated as non-empty and ≥ 5 words. Build-time only — not invoked at server startup. |
69
69
  | Lint (conformance) | If the handler `throw new McpError(JsonRpcErrorCode.X)` outside `ctx.fail`, conformance check warns when X isn't declared. The inverse is checked too: a declared reason no `ctx.fail` in the handler names warns as `error-contract-unthrown` (mark it `thrownBy: 'service'` when the service layer produces it). |
@@ -134,14 +134,14 @@ Values are the logger's own level names below `error` — `debug`, `info`, `noti
134
134
 
135
135
  | Surface | Under a declared severity |
136
136
  |:--------|:--------------------------|
137
- | The `Error in tool:<name>` log record | Emitted at the declared level. Same message, same structured fields, the stack included — except an argument rejection's record, which is bounded and carries no stack (see below). |
137
+ | The `Error in tool:<name>` log record | Emitted at the declared level. Same message, same structured fields, the throw site's stack included — except the framework's own refusals, whose records carry no stack, an argument rejection's bounded as well (see below). |
138
138
  | `mcp.errors.classified` | Gains an `mcp.error.severity` attribute. The `reason` itself never becomes a metric attribute. |
139
139
  | `isError`, the JSON-RPC code, `structuredContent.error`, `content[]` | Byte-identical to the undeclared case. |
140
140
  | Span status, `mcp.tool.calls`, `mcp.tool.duration`, `mcp.tool.errors` | Unchanged — the call still failed, and splitting those series would redefine what an error rate means. |
141
141
 
142
142
  **Tools only.** Resolution happens in the tool handler factory, against the thrown error's `data.reason` — the same reason-to-entry lookup that fills `data.recovery`. Resources declare `errors[]` but write no failure record, so the field is accepted there and inert. A reason thrown below the handler that the contract never declared, an entry with no `severity`, and a non-`McpError` throw all keep `error`. A cancelled request keeps its own `info`, stack-free path regardless.
143
143
 
144
- **The framework's own refusals log at `notice`.** An argument rejection (`invalid_arguments`, raised only by the schema gate before the handler runs), a `ctx.requestInput` the connection cannot serve (`client_capability_missing`), and a missing-scope refusal (the `Forbidden` the inline `auth` check or `checkScopes` throws) are routine caller or connection traffic, not server faults, so their `Error in tool:<name>` record — and the failure-payload record when `LOG_TOOL_FAILURE_PAYLOADS=true` — is emitted at `notice`, and `mcp.errors.classified` counts them with `mcp.error.severity: "notice"`. Nothing to declare; an `errors[]` entry naming `invalid_arguments` or `client_capability_missing` with its own `severity` still wins, while a missing-scope refusal carries no `data.reason`, so its level is fixed. That refusal is recognized by where it was raised, never by its code: a handler's own `forbidden()`, an upstream 403 mapped by `httpErrorFromResponse`, and a missing auth context (`Unauthorized`) keep `error` and the stack. The missing-scope and argument-rejection records log no stack, and an argument rejection's record is bounded whatever the caller sends: the message, `recovery.hint`, each issue's `message`, each `data.input` key, and every other string keep at most their first 1,024 characters and every array its first 10 entries, with the uncut length or count beside each cut (`originalMessageLength`, `<field>Length`, `<field>Count`, `<field>Lengths`). The wire envelope and `mcp.tool.rejections` are unchanged — the `-32602` result still carries every key and issue whole — and a schema that wrongly rejects valid calls still shows per tool on `mcp.tool.rejections`.
144
+ **The framework's own refusals log at `notice`.** An argument rejection (`invalid_arguments`, raised only by the schema gate before the handler runs), a `ctx.requestInput` the connection cannot serve (`client_capability_missing`), and a missing-scope refusal (the `Forbidden` the inline `auth` check or `checkScopes` throws) are routine caller or connection traffic, not server faults, so their `Error in tool:<name>` record — and the failure-payload record when `LOG_TOOL_FAILURE_PAYLOADS=true` — is emitted at `notice`, and `mcp.errors.classified` counts them with `mcp.error.severity: "notice"`. Nothing to declare; an `errors[]` entry naming `invalid_arguments` or `client_capability_missing` with its own `severity` still wins, while a missing-scope refusal carries no `data.reason`, so its level is fixed. That refusal is recognized by where it was raised, never by its code: a handler's own `forbidden()`, an upstream 403 mapped by `httpErrorFromResponse`, and a missing auth context (`Unauthorized`) keep `error` and the stack. All three records log no stack, whatever level an entry declares, and an argument rejection's record is bounded whatever the caller sends: the message, `recovery.hint`, each issue's `message`, each `data.input` key, and every other string keep at most their first 1,024 characters and every array its first 10 entries, with the uncut length or count beside each cut (`originalMessageLength`, `<field>Length`, `<field>Count`, `<field>Lengths`). The wire envelope and `mcp.tool.rejections` are unchanged — the `-32602` result still carries every key and issue whole — and a schema that wrongly rejects valid calls still shows per tool on `mcp.tool.rejections`.
145
145
 
146
146
  **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.
147
147
 
@@ -198,7 +198,7 @@ A best-effort call that catches and degrades must still rethrow on `ctx.signal?.
198
198
 
199
199
  ## Error Factories (fallback)
200
200
 
201
- Use when no contract entry fits — ad-hoc throws, tools without a contract, or service-layer code. Shorter than `new McpError(...)` and self-documenting. All return `McpError` instances and accept an optional `options` parameter for error chaining via `{ cause }`.
201
+ Use when no contract entry fits — ad-hoc throws, tools without a contract, or service-layer code. Shorter than `new McpError(...)` and self-documenting. All return `McpError` instances and accept an optional `options` parameter for error chaining via `{ cause }`. Each one's stack starts at the line that called it, with the factory's own frame cut.
202
202
 
203
203
  ```ts
204
204
  throw notFound('Item not found', { itemId: '123' });
@@ -206,7 +206,7 @@ throw validationError('Missing required field: name', { field: 'name' });
206
206
  throw unauthorized('Token expired');
207
207
 
208
208
  // With cause for error chaining
209
- throw serviceUnavailable('API call failed', { url }, { cause: error });
209
+ throw serviceUnavailable('API call failed', { endpoint: 'search' }, { cause: error });
210
210
  ```
211
211
 
212
212
  **Available factories:**
@@ -243,7 +243,7 @@ throw new McpError(code, message?, data?, options?)
243
243
 
244
244
  - `code` — a `JsonRpcErrorCode` enum value
245
245
  - `message` — optional human-readable description of the failure
246
- - `data` — optional structured data (plain object), returned to the client verbatim. Pass the explicit fields the caller acts on (the rejected key, a limit, a `reason`), never `ctx` or another request context: a handler `ctx` carries request metadata and, after an elicitation round, what the user typed. Framework helpers follow the same rule — a storage, parser, or formatter failure carries only its offending field or a `reason`, whatever context you pass them.
246
+ - `data` — optional structured data (plain object), returned to the client verbatim. Pass the explicit fields the caller acts on (the rejected key, a limit, a `reason`), never `ctx` or another request context: a handler `ctx` carries request metadata and, after an elicitation round, what the user typed. Framework helpers follow the same rule — a storage, parser, or formatter failure carries only its offending field or a `reason`, whatever context you pass them. A `filesystem` storage fault names the key, never the host path: `DatabaseError`, or `ValidationError` when a key segment is too long for the filesystem, with the raw `fs` error on `cause` for the log.
247
247
  - `options` — optional `{ cause?: unknown }` for error chaining
248
248
 
249
249
  **Example:**
@@ -311,7 +311,7 @@ The framework applies these steps in order — first match wins:
311
311
  8. **`AbortError` name** — `error.name === 'AbortError'` → `Timeout`.
312
312
  9. **Fallback** — `InternalError`.
313
313
 
314
- 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`.
314
+ 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, nor the `originalStack` or `causeChain` node stacks the thrown `McpError`'s `data` carries, nor an `Error`'s anywhere in the record (`errorData`, `input`, the context's `extra`). 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`.
315
315
 
316
316
  The code this ladder picks is the one the caller receives, and it is also the origin every error counter records: `mcp.tool.error_category`, `mcp.prompt.error_category`, and `mcp.error.category` on `mcp.errors.classified` all bucket that same code, so a plain `Error('Request timed out')` files as `upstream` everywhere, never `server` on one counter and `upstream` on another. See `api-telemetry`'s Error category.
317
317
 
@@ -412,7 +412,7 @@ Important properties:
412
412
  - **`reason`, `retryable`, and `requestId` render as a trailing term line.** `(reason malformed_id · not retryable · request UTFAC-QE0MB)` closes the text whenever `data.reason` is a non-empty string, `data.retryable` is a boolean, or `data.requestId` is a non-empty string — `retryable` for `true`, `not retryable` for `false`, in that order. None present (an `McpError` with no `data` built outside a request, as `runToolContract` does) appends nothing at all. The numeric `code` and `data.issues` stay JSON-only on purpose: the code is the one envelope field a model cannot act on, and the message already renders each issue as a sentence. A consumer test pinning `content[0].text` exactly, rather than asserting it contains the diagnostic, therefore moves for any error carrying a reason.
413
413
  - **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.
414
414
  - **`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 root key names the root properties the tool does accept, an unknown key inside a nested strict object names its full path and that object's own keys (`Unknown key opts.b. opts accepts: a.`, `Unknown key items.1.b. items.1 accepts: name.`), a wrong type names the type to send instead (a fractional number on an integer field reads `Send rows as an integer, not a fractional number.`), missing fields collapse into one `Provide …` sentence, and anything else restates its diagnostic line, field path included (`start: Must be a parseable ISO 8601 date`), so identical constraints on different fields stay distinguishable. When every sentence restates an issue, the hint is the message's issue text verbatim, and its `Recovery:` line is dropped from `content[]`. Beside a framework sentence each restatement is terminated and a repeated sentence appears once: `start: Must be a parseable ISO 8601 date. Send n as a number, not a string.` When pre-validation rewrote or dropped a key the caller wrote, the rejection says so, since its issues name only the keys that were validated: `data.input` carries `{ aliased: [{ alias, target }], ignored: [...] }` — keys only, in argument order, `ignored` holding the undeclared underscore-prefixed keys the drop discarded — and the hint closes with `Validated query as targetQuery.` / `Dropped undeclared key _max.`, framework sentences that keep the `Recovery:` line. An ignore-list drop (`_meta`, `toolCallId`, a server's `input.ignoreKeys`) is a client artifact and is never reported, and a rejection the step changed nothing on carries no `data.input`. The reason renders on the closing `(reason invalid_arguments · request <id>)`; this path sets no `retryable`. Its `Error in tool:<name>` record logs at `notice`, not `error`, with no stack and its caller-sized strings and arrays capped (see `severity` above). Authors declare nothing for this: the rejection happens before the handler and the hint is derived from the schema.
415
- - **`client_capability_missing` is the other framework-owned reason.** When a handler returns `ctx.requestInput({ inputRequests: … })` on a 2025-era connection whose client declared no matching capability, `ctx.requestInput` throws this failure in place of the input-required signal, before anything reaches the wire. It is an ordinary handler throw from there on, logged at `notice`: measured as the failed call it is, and shaped by the family's usual error path — a tool gets `structuredContent.error.code = -32600` (`InvalidRequest`), `data.reason: "client_capability_missing"`, and a `data.recovery.hint` naming the capability; a resource read gets the same code, reason, and hint through the JSON-RPC error envelope. The hint ends at reconnecting with a client that declares the capability, since a consent gate has no argument that could stand in for its answer; a handler whose arguments can appends its own sentence per call with `ctx.requestInput(spec, { fallbackHint })`. Like `invalid_arguments`, a definition cannot declare it in `errors[]`: it names a property of the connection, not a domain outcome. See `api-context`'s `ctx.requestInput`.
415
+ - **`client_capability_missing` is the other framework-owned reason.** When a handler returns `ctx.requestInput({ inputRequests: … })` on a 2025-era connection whose client declared no matching capability, `ctx.requestInput` throws this failure in place of the input-required signal, before anything reaches the wire. It is an ordinary handler throw from there on, logged at `notice` with no stack: measured as the failed call it is, and shaped by the family's usual error path — a tool gets `structuredContent.error.code = -32600` (`InvalidRequest`), `data.reason: "client_capability_missing"`, and a `data.recovery.hint` naming the capability; a resource read gets the same code, reason, and hint through the JSON-RPC error envelope. The hint ends at reconnecting with a client that declares the capability, since a consent gate has no argument that could stand in for its answer; a handler whose arguments can appends its own sentence per call with `ctx.requestInput(spec, { fallbackHint })`. Like `invalid_arguments`, a definition cannot declare it in `errors[]`: it names a property of the connection, not a domain outcome. See `api-context`'s `ctx.requestInput`.
416
416
  - **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)` against a declared `errors[]` entry, whose `recovery` the framework puts on the wire, 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.
417
417
  - **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.
418
418
  - **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` restates the same line, field path included.
@@ -451,7 +451,7 @@ const result = await ErrorHandler.tryCatch(
451
451
  () => externalApi.fetch(url),
452
452
  {
453
453
  operation: 'ExternalApi.fetch',
454
- context: { url },
454
+ context: { extra: { endpoint: 'fetch' } },
455
455
  errorCode: JsonRpcErrorCode.ServiceUnavailable,
456
456
  },
457
457
  );
@@ -465,20 +465,22 @@ const parsed = await ErrorHandler.tryCatch(
465
465
  );
466
466
  ```
467
467
 
468
- `tryCatch` always logs and rethrows — it never swallows errors. The `fn` argument may be synchronous or return a `Promise`; both are handled via `Promise.resolve(fn())`.
468
+ `tryCatch` always logs and rethrows — it never swallows errors. The `fn` argument may be synchronous or return a `Promise`; both are handled via `Promise.resolve(fn())`. The rethrown `McpError` carries the caught error's stack verbatim, so it starts at the throw site and its first line names the class that was thrown.
469
469
 
470
- **The thrown error's `data` is wire-visible.** A handler that lets it propagate forwards it as `structuredContent.error.data` (tools) or JSON-RPC `error.data` (resources, prompts). It carries the caught `McpError`'s own `data`, `originalErrorName`, `originalMessage`, and `rootCause` (`{ name, message }`) — never a stack and never `context`: `originalStack`, the full `causeChain`, and every `context` field (`requestId`, `sessionId`, `traceId`, `tenantId`, `extra`, …) go to the log record only. A field the caller should act on belongs in the thrown `McpError`'s `data`, not in `context`. (The handler factory adds the call's own `data.requestId` when it builds the envelope; that value never comes from `context` here.)
470
+ **A field that cannot be read is written as `'[Unreadable]'`.** When reading the caught error's `name`, `message`, `stack`, or `cause` throws — a getter, a revoked `Proxy` on `cause` — the record writes that field as `'[Unreadable]'` (the rethrown `McpError` then keeps its own stack), and an unreadable cause ends `causeChain` as a node whose `name` and `message` are both `'[Unreadable]'`. A caught value that cannot be inspected at all, such as a revoked `Proxy`, is handled as a non-Error whose name and message are `'[Unreadable]'`; a caught `McpError` whose `code` cannot be read is classified `InternalError`, and one whose `data` cannot be read or copied (a getter, a revoked `Proxy`) is handled without it, under an `errorMapper` that returns the error it was given too. The record is written and `tryCatch` still throws the rebuilt `McpError`. A tool, resource, or prompt handler that threw such a value — an Error whose `name`, `message`, `stack`, or `cause` cannot be read, an `McpError` whose `code` or `data` cannot be read, a revoked `Proxy` — gets its normal error envelope with `data.requestId`, and its record: the code wherever it can be read, `'[Unreadable]'` for a message that cannot, the thrown `data` left out when it cannot be read, and, for a declared failure, its contract's `recovery` hint. That holds when the value arrives through `withSpan` or through `tryCatch` with an identity `errorMapper`. A `name` or `message` that is not a string is written as text: `String` converts any other primitive (`404` reads `'404'`, a Symbol `'Symbol(…)'`), and an object, whose conversion would run its own `toString`, is `'[Unreadable]'`. Each field of a thrown `McpError`'s `data` that the wire cannot carry — one that throws on read at any depth, a `BigInt`, a cycle, a `toJSON` that throws — is `'[Unreadable]'` on the envelope and in the record, since a response holding it would never be sent and the client would wait; every other field goes out exactly as thrown. `determineErrorCode`, `classifyOnly`, `formatError` (whose `data` is then `{}`), and `mapError` never throw on the value they inspect either.
471
+
472
+ **The thrown error's `data` is wire-visible.** A handler that lets it propagate forwards it as `structuredContent.error.data` (tools) or JSON-RPC `error.data` (resources, prompts). It carries the caught `McpError`'s own `data`, `originalErrorName`, and `originalMessage` — nothing derived from a cause, never a stack, and never `context`: `rootCause` (`{ name, message }` of the deepest cause), the full `causeChain`, the throw-site stack, and every `context` field (`requestId`, `sessionId`, `traceId`, `tenantId`, `extra`, …) go to the log record only. A message redacted at the throw site therefore stays redacted on the wire while the raw error rides `cause` into the log. A field the caller should act on belongs in the thrown `McpError`'s `data`, not in `context`. (The handler factory adds the call's own `data.requestId` when it builds the envelope; that value never comes from `context` here.)
471
473
 
472
474
  **Options** (`Omit<ErrorHandlerOptions, 'rethrow'>`):
473
475
 
474
476
  | Option | Type | Required | Purpose |
475
477
  |:-------|:-----|:--------:|:--------|
476
478
  | `operation` | `string` | Yes | Name logged with the error |
477
- | `context` | `ErrorContext` | No | Structured fields merged into the log record only — never the thrown error's client-visible `data`; `requestId` and `timestamp` receive special treatment |
479
+ | `context` | `ErrorContext` | No | Structured fields merged into the log record only — never the thrown error's client-visible `data`; `requestId` and `timestamp` receive special treatment. The handler's own fields (`errorCode`, the type names, `errorData`, `stack`) lead the record, so the log walk reaches `errorData` before a large `extra` or `input` can spend its bound on what it writes, and no `extra` key replaces one |
478
480
  | `errorCode` | `JsonRpcErrorCode` | No | Code used if the caught error is not already an `McpError` |
479
481
  | `input` | `unknown` | No | Input value sanitized and logged alongside the error |
480
482
  | `critical` | `boolean` | No | Marks the error as critical in logs (default `false`) |
481
- | `includeStack` | `boolean` | No | Stack traces in the log record (default `true`). `false` drops every one — `stack`, `errorData.originalStack` (also one carried in a caught `McpError`'s `data`), and each `causeChain` node's `stack` — and keeps the chain itself; the thrown error and the span exception are unaffected |
483
+ | `includeStack` | `boolean` | No | Stack traces in the log record (default `true`: the record's `stack` is the throw site's — none for a thrown value without a stack, and never the context's `extra.stack` — and each stack is written once: a `causeChain` node carrying the record's stack, or the same stack as the node before it, is written without it). `false` makes the record stack-free, as a cancellation's always is: no `stack`, no `errorData.originalStack`, no `causeChain` node `stack` or node `data.originalStack`, and every `Error` in the record (`errorData`, `input`, the context's `extra`) written without one — whoever supplied the field, a caught `McpError`'s `data` included. Any other key named `stack` is the caller's data, written as given. The chain itself stays; the thrown error and the span exception are unaffected |
482
484
  | `errorMapper` | `(error: unknown) => Error` | No | Custom transform applied instead of default `McpError` wrapping |
483
485
 
484
486
  ---
@@ -507,7 +509,7 @@ Full status table:
507
509
 
508
510
  | Status | Code |
509
511
  |:-------|:-----|
510
- | 3xx | `InvalidRequest` — reachable under `redirect: 'manual'`, and outside `withRetry`'s transient set since re-issuing returns the same redirect |
512
+ | 3xx | `InvalidRequest` — reachable when not followed (`redirect: 'manual'`, or no `Location`, a 304 included), and outside `withRetry`'s transient set since re-issuing returns the same redirect |
511
513
  | 400 | `InvalidParams` |
512
514
  | 401 | `Unauthorized` |
513
515
  | 402, 403 | `Forbidden` |
@@ -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.21"
7
+ version: "1.22"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -266,8 +266,9 @@ Evaluated on the emitted schema rather than on the Zod schema, because the two d
266
266
 
267
267
  | What you wrote | What is emitted |
268
268
  |:--|:--|
269
- | `z.enum([1, 2, 3, 4, 5])` — a numeric array handed to a string-only constructor | `{"type": "string", "enum": []}` |
270
- | `z.enum([])` | `{"type": "string", "enum": []}` |
269
+ | `z.enum([1, 2, 3, 4, 5])` — a numeric array handed to a string-only constructor | `{"not": {}}` |
270
+ | `z.enum([])` | `{"not": {}}` |
271
+ | `.meta({ enum: [] })` / `.meta({ oneOf: [] })` / `.meta({ type: [] })` | the empty set as written |
271
272
  | `z.union([])` | `{"anyOf": []}` |
272
273
  | `z.never()` | `{"not": {}}` |
273
274
 
@@ -448,7 +449,7 @@ Also applies to resources and prompts (same rule ID, different `definitionType`)
448
449
 
449
450
  **Severity:** error
450
451
 
451
- Every tool must have a `handler` function (or `taskHandlers` object for task tools). Every resource must have a `handler`. Definitions without handlers can't do anything at runtime.
452
+ Every tool must have a `handler` function. Every resource must have a `handler`. Definitions without handlers can't do anything at runtime.
452
453
 
453
454
  Also applies to resources (same rule ID, different `definitionType`).
454
455
 
@@ -662,7 +663,7 @@ Most of these are mechanical — fix the manifest field named in the diagnostic'
662
663
 
663
664
  ## Landing config rules
664
665
 
665
- Validate the `landing` config passed to `createApp()` (the config object that drives the framework's landing page). Run only when `input.landing` is provided to `validateDefinitions`. All errors — landing config that's structurally broken would render incorrectly on the public page.
666
+ Validate the `landing` config passed to `createApp()` (the config object that drives the framework's landing page). Run only when `input.landing` is provided to `validateDefinitions`. Structural breakage is an error — it would render incorrectly on the public page. Input the page tolerates (extras it drops, an empty override it falls back from, an unconventional env-var name) is a warning.
666
667
 
667
668
  | Rule | Severity | Catches |
668
669
  |:-----|:---------|:--------|
@@ -672,20 +673,20 @@ Validate the `landing` config passed to `createApp()` (the config object that dr
672
673
  | `landing-logo-type` | error | `logo` is present but not a string |
673
674
  | `landing-logo-size` | error | `logo` is too long for inline rendering |
674
675
  | `landing-links-type` | error | `links` is present but not an array |
675
- | `landing-links-count` | error | `links` exceeds the max count |
676
+ | `landing-links-count` | warning | `links` exceeds the max count — extras are dropped |
676
677
  | `landing-link-shape` | error | A `links[]` entry is not a plain object |
677
678
  | `landing-link-href` | error | A link entry's `href` is missing or not a non-empty string |
678
679
  | `landing-link-label` | error | A link entry's `label` is missing or not a non-empty string |
679
680
  | `landing-repo-root-type` | error | `repoRoot` is present but not a string |
680
681
  | `landing-repo-root-shape` | error | `repoRoot` is not a recognized GitHub URL shape |
681
682
  | `landing-env-example-type` | error | `envExample` is present but not a plain object |
682
- | `landing-env-example-count` | error | `envExample` has too many entries |
683
- | `landing-env-example-key` | error | An `envExample` key is empty or invalid |
683
+ | `landing-env-example-count` | warning | `envExample` has too many entries — extras are dropped |
684
+ | `landing-env-example-key` | warning | An `envExample` key is not SCREAMING_SNAKE_CASE |
684
685
  | `landing-env-example-value` | error | An `envExample` value is not a string |
685
686
  | `landing-connect-snippets-type` | error | `connectSnippets` is present but not a plain object |
686
- | `landing-connect-snippets-key` | error | A `connectSnippets` key is empty |
687
+ | `landing-connect-snippets-key` | warning | A `connectSnippets` key is not a recognized tab id — it is dropped |
687
688
  | `landing-connect-snippets-value` | error | A `connectSnippets` value is not a string |
688
- | `landing-connect-snippets-empty` | error | A `connectSnippets` value is an empty string |
689
+ | `landing-connect-snippets-empty` | warning | A `connectSnippets` value is an empty string — the derived snippet is used |
689
690
  | `landing-theme-type` | error | `theme` is present but not a plain object |
690
691
  | `landing-theme-accent` | error | `theme.accent` is present but not a string |
691
692
  | `landing-theme-accent-format` | error | `theme.accent` doesn't match the expected color format |
@@ -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.18"
7
+ version: "1.19"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -41,7 +41,7 @@ Metrics push via `PeriodicExportingMetricReader` every **15 seconds**. Traces us
41
41
 
42
42
  Traces and metrics endpoints resolve per the [OTLP exporter spec](https://opentelemetry.io/docs/specs/otel/protocol/exporter/#endpoint-urls-for-otlphttp): the signal-specific variable as-is, else the base plus the signal path. A signal with no resolved endpoint exports nothing, and `NodeSDK`'s own `OTEL_METRICS_EXPORTER` / `OTEL_LOGS_EXPORTER` defaults are not consulted.
43
43
 
44
- Log records export only when `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` is set. The base endpoint alone never turns it on, so a deployment exporting traces and metrics keeps its logs local until it opts in. When set, every record the framework logger writes — after the `MCP_LOG_LEVEL` filter and the rate limit, with the same field redaction as the pino output — is also sent through a `BatchLogRecordProcessor`, with its MCP level as the severity and the active span's trace context (a handler's `ctx.log` record joins its `tool_execution:*` span). `interactions.log` transcripts are never exported. Log export needs three more optional peers: `bun add @opentelemetry/sdk-logs @opentelemetry/exporter-logs-otlp-http @opentelemetry/api-logs`.
44
+ Log records export only when `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` is set. The base endpoint alone never turns it on, so a deployment exporting traces and metrics keeps its logs local until it opts in. When set, every record the framework logger writes — after the `MCP_LOG_LEVEL` filter and the rate limit, with the same fields as the pino output (the error argument as the `exception.*` attributes rather than an `err` field), redacted the same way — is also sent through a `BatchLogRecordProcessor`, with its MCP level as the severity and the active span's trace context (a handler's `ctx.log` record joins its `tool_execution:*` span). `interactions.log` transcripts are never exported. Log export needs three more optional peers: `bun add @opentelemetry/sdk-logs @opentelemetry/exporter-logs-otlp-http @opentelemetry/api-logs`.
45
45
 
46
46
  ---
47
47
 
@@ -216,7 +216,7 @@ A definition may put `severity` on an `errors[]` entry — `debug`, `info`, `not
216
216
  - The `Error in tool:<name>` log record is emitted at that level instead of `error`, with the same message and structured fields.
217
217
  - `mcp.errors.classified` gains `mcp.error.severity` on that record. It is set only when a severity resolved below `error`.
218
218
 
219
- The framework's own refusals resolve one without a declaration: an argument rejection (`invalid_arguments`), a `ctx.requestInput` the client connection cannot serve (`client_capability_missing`), and a missing-scope refusal from the inline `auth` check or `checkScopes` log at `notice`, so even a server that declares no severity sees `mcp.error.severity: "notice"` on those `mcp.errors.classified` increments — a bounded split a dashboard can use to separate caller rejections from faults. A missing auth context (`-32006`), a handler's own `forbidden()`, and an upstream 403 stay at `error`. An argument rejection and an inline missing-scope refusal open no execution span and reach no call counter either way; each still counts once on `mcp.tool.rejections`. Neither the argument-rejection nor the missing-scope record carries a stack.
219
+ The framework's own refusals resolve one without a declaration: an argument rejection (`invalid_arguments`), a `ctx.requestInput` the client connection cannot serve (`client_capability_missing`), and a missing-scope refusal from the inline `auth` check or `checkScopes` log at `notice`, so even a server that declares no severity sees `mcp.error.severity: "notice"` on those `mcp.errors.classified` increments — a bounded split a dashboard can use to separate caller rejections from faults. A missing auth context (`-32006`), a handler's own `forbidden()`, and an upstream 403 stay at `error`. An argument rejection and an inline missing-scope refusal open no execution span and reach no call counter either way; each still counts once on `mcp.tool.rejections`. None of the three refusal records carries a stack, whatever level an `errors[]` entry declares.
220
220
 
221
221
  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 write no failure record of their own. A cancelled request keeps its own `info`, stack-free path whatever the contract declares. See `api-errors`.
222
222
 
@@ -247,6 +247,8 @@ Auto-registered when `process.memoryUsage` / `process.uptime` / `perf_hooks` are
247
247
 
248
248
  Every framework log record carries `requestId`, `traceId`, `spanId`, and `tenantId` from the request context, so every log line is searchable by trace. `@opentelemetry/instrumentation-pino` does not touch these records: it patches only a `pino` loaded after the SDK starts. To ship the records to the same backend as traces, set `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` (see Enabling export).
249
249
 
250
+ **Errors and nested data.** An `Error` anywhere in a record — under any key, at any depth, and the `err` field an error argument rides in the process log, which leads the record so a line the walk cuts still says what failed — is written as `type` (its `name`), `message`, `stack`, a string `code` (or an `McpError`'s numeric `code` and its `data`), and `cause` and an `AggregateError`'s `errors` in the same shape. A `cause` or member whose stack equals its parent's is written without it, so a rethrown `tryCatch` error and the original on its `cause` carry the throw-site stack once. No other own property is written, so a runtime's request URL (`path`, `input`) or file path (`sourceURL`) stays out of the log; a URL inside an error's own message is written as-is. Objects are kept through 15 levels below the record root, and one 16 levels down is written as `'[MaxDepth]'`. A reference back to an enclosing object is written as `'[Circular]'`. One walk writes at most 16 MiB (16,777,216 characters) of strings, field names, and primitives, repeated or not, each charged about the characters it writes (a string with its quotes, a field name with its quotes, colon, and comma), then writes `'[Truncated]'` — for a field name, `'[Truncated]': '[Truncated]'` — and stops: a 10 MB string is written whole and a 20 MB one is cut, and data a getter builds on every read with 50 fields of a 1,000-character string per object writes 16 MiB, not 284 MB. Repeated content — an object reached again through a shared reference and everything beneath it, or a string or field name of 1,024 characters or more written again — is also charged against 1,000,000 per record and written as `'[Truncated]'` where that runs out. A later repeat that still fits is written, so 20,000 rows sharing one `tags: []` come out whole, while a graph whose 17 objects each refer to the next three times writes 0.4 MB, not 380 MB. One walk also makes at most 400,000 reads — one per object, one per field or array element that is not an object (a redacted field included), ten per object 16 levels down — then writes `'[Truncated]'` and stops, which bounds data a getter, Proxy, or `toJSON()` builds on every read: 0.8 MB in milliseconds, not 308 MB in seconds, and 3.7 MB when each object it builds carries 200 numeric fields. A record of 100,000 distinct one-field objects takes half those reads and is written whole. A value whose read throws — a getter, a Proxy trap, a revoked Proxy — is written as `'[Unreadable]'`, and a class instance (`AbortSignal`, `Map`) is dropped unread. A key is redacted at every depth kept when some run of its adjacent words, joined, equals a sensitive field name, case and separators ignored. Words split at every character other than a letter or digit, at a lowercase letter followed by a capital, at the end of a run of capitals, and around each run of digits: `apiKey`, `API_KEY`, `x-api-key`, `accessToken`, `upstream_private_key`, and `apiKey2` are redacted, `max_tokens`, `MAX_TOKENS`, and `tokenizer` are not. That walk is the only redaction, and `sanitization.setSensitiveFields` extends it. The correlation fields a record's context supplies at its root — `requestId`, `sessionId`, `tenantId`, `traceId`, `spanId`, `timestamp`, `operation` — are never redacted. An added name matching one (`session_id`, `id`) still redacts a caller's own key of that name, and the same key on `interactions.log`, which carries no record context. stderr, the files, and `interactions.log` write the same object. A record key named after a field either pino logger writes on its lines itself — `level`, `time`, `msg`, `env`, `version`, `pid`, and `hostname`, and `err` on a line carrying an error argument — is written as `data_<name>` in the process log and `interactions.log` alike (`data_data_<name>` when that name is taken too), so the line carries one of each, its own: a transport routes it by its own `level` (a caller's `level: 60` on an info record stays out of `error.log`), and a parser reads the server's `version`, not the caller's. The OTLP export writes it too, except the error argument, which goes out as the `exception.*` attributes (type, message, stack), so its `cause`, `code`, and `data` stay in the process log. The `ctx.log` mirror to the client applies the same bounds, markers, and matcher, and writes an `Error` as `{ type, message }` only.
251
+
250
252
  For domain logging inside handlers, use `ctx.log` (`debug`/`info`/`notice`/`warning`/`error`) — auto-includes `requestId`, `traceId`, `tenantId`, `spanId`. The completion log emitted at the end of every handler — at `info`, whatever the outcome — carries a `metrics` payload, with fields tuned to each surface:
251
253
 
252
254
  | Handler | Log message | `metrics` fields |
@@ -273,7 +275,7 @@ Off by default. With `LOG_TOOL_FAILURE_PAYLOADS=true`, a failed tool call writes
273
275
  | `toolResult` | The `CallToolResult` the tool returned. On 2026-07-28 the SDK adds `resultType` and `_meta` serverInfo on the wire after the record is written |
274
276
  | `toolInputTruncated` / `toolResultTruncated` | Whether that payload was cut at `LOG_TOOL_FAILURE_PAYLOAD_MAX_BYTES` (default `16384`) |
275
277
 
276
- Each payload is redacted with `sanitization.sanitizeForLogging`, serialized, then cut on a UTF-8 character boundary, each on its own. They are strings, not objects, because the logger drops values nested deeper than four levels. Covered: `auth` refusals, argument rejections (`-32602`), handler throws, and output/enrichment contract failures. Nothing is written for a success, a `RequestCancelled`, or an `input_required` return, nor for resource and prompt failures.
278
+ Each payload is redacted with `sanitization.sanitizeForLogging`, serialized, then cut on a UTF-8 character boundary, each on its own. They are strings, not objects, so the byte cap bounds each one and a payload of any depth is written whole up to it — within the 16 MiB of characters one record's walk writes — never cut at the logger's 16-level depth bound. Covered: `auth` refusals, argument rejections (`-32602`), handler throws, and output/enrichment contract failures. Nothing is written for a success, a `RequestCancelled`, or an `input_required` return, nor for resource and prompt failures.
277
279
 
278
280
  The record goes wherever the error record goes: stderr, `combined.log`, and OTLP when `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` is set. On Workers, where no file sink exists, set the flag as a Worker binding. It passes the `MCP_LOG_LEVEL` filter and the rate limit like any record, and its message is constant per tool, so when one tool fails more than `MCP_LOG_RATE_LIMIT_THRESHOLD` times in a window, only the first payloads are kept. **Redaction matches key names only.** A secret inside a free-form value, such as a token pasted into a `query` or a connection string in an error message, is written as-is. Enable it only where the log store is trusted with caller data.
279
281
 
@@ -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.16"
7
+ version: "2.17"
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`), `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; an abort whose reason is a `TimeoutError` — `AbortSignal.timeout()`, or an `AbortSignal.any` whose timeout member fired — is a deadline instead, and throws `Timeout` (-32004) with `data.errorSource: 'FetchSignalTimeout'`, distinct from the helper's own `timeoutMs` expiry, `'FetchTimeout'`, and likewise outside `withRetry`'s default transient set, since every retry would reuse the fired signal). Validation rejections carry `data.reason` and a `recovery.hint`: `invalid_url` (not an absolute `http:`/`https:` URL, including a redirect target), `private_address_blocked` (the SSRF guard refused the host by name, literal IP, or DNS answer), `too_many_redirects` (past the 5-hop cap, with `data.maxRedirects`). 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:** every thrown message and log record names the request by its origin plus elision markers — `https://api.example.com/sk-key/reverse?lat=1` reads `https://api.example.com/…?…`, and a root URL keeps its bare origin — so a credential in the path (`/bot<token>/…`, webhook URLs) or the query (`?api-key=…`, `?api_key=…`) never reaches the client or the logs. A URL the runtime quotes in its own rejection message is reduced the same way. The actual request still uses the full URL. To tell endpoints apart in the logs, label the call through its context: `fetchWithTimeout(url, ms, withExtra(ctx, { endpoint: 'reverse' }))` (`withExtra` from `/utils`) puts `endpoint` on every record for the call. The label is a field, not part of the message, so calls to one origin share one budget under the logger's per-message rate limit (`MCP_LOG_RATE_LIMIT_THRESHOLD` records per `MCP_LOG_RATE_LIMIT_WINDOW_MS`, default 10 a minute) whatever endpoint they name; repeats past it are dropped and reported later as a `Suppressed N` line. A network-level failure throws `ServiceUnavailable` (`data.errorSource: 'FetchNetworkErrorWrapper'`) with the runtime's rejection as `cause`, so its transport code survives — on the rejection itself under Bun (`ConnectionRefused`), on its `cause` under Node (`ECONNREFUSED`) — and the failure record carries the same `causeChain` field as `withRetry`'s retry record. |
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`. Each `Retry N/M for <operation>: <message> — waiting Xms` debug record adds `causeChain` to the context's `extra` when the retried error has a cause or a string `code` — `{ name, message, code? }` per node, the error itself first, so a transport code (`ECONNRESET`, `ConnectionRefused`) survives a retry that later succeeds; projections only, never a raw `Error`, a stack, or `McpError.data`. An error with neither logs exactly as before. **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). |
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 follows every 3xx carrying a `Location` 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; an abort whose reason is a `TimeoutError` — `AbortSignal.timeout()`, or an `AbortSignal.any` whose timeout member fired — is a deadline instead, and throws `Timeout` (-32004) with `data.errorSource: 'FetchSignalTimeout'`, distinct from the helper's own `timeoutMs` expiry, `'FetchTimeout'`, and likewise outside `withRetry`'s default transient set, since every retry would reuse the fired signal). Validation rejections carry `data.reason` and a `recovery.hint`: `invalid_url` (not an absolute `http:`/`https:` URL, including a redirect target), `private_address_blocked` (the SSRF guard refused the host by name, literal IP, or DNS answer), `too_many_redirects` (past the 5-hop cap, with `data.maxRedirects`). A redirect hop's rejection is thrown with no record from `fetchWithTimeout`, as on the initial URL, so the caller's record — at a tool's declared `severity` — is the only one. 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; a 3xx without `Location` (304 included) is not followed and fails as a non-2xx (`InvalidRequest`, not retried), as it does without the option. **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:** every thrown message and log record names the request by its origin plus elision markers — `https://api.example.com/sk-key/reverse?lat=1` reads `https://api.example.com/…?…`, and a root URL keeps its bare origin — so a credential in the path (`/bot<token>/…`, webhook URLs) or the query (`?api-key=…`, `?api_key=…`) never reaches the client or the logs. A URL the runtime quotes in its own rejection message is reduced the same way. The actual request still uses the full URL. To tell endpoints apart in the logs, label the call through its context: `fetchWithTimeout(url, ms, withExtra(ctx, { endpoint: 'reverse' }))` (`withExtra` from `/utils`) puts `endpoint` on every record for the call. The label is a field, not part of the message, so calls to one origin share one budget under the logger's per-message rate limit (`MCP_LOG_RATE_LIMIT_THRESHOLD` records per `MCP_LOG_RATE_LIMIT_WINDOW_MS`, default 10 a minute) whatever endpoint they name; repeats past it are dropped and reported later as a `Suppressed N` line. A network-level failure — an unparseable redirect `Location` included — throws `ServiceUnavailable` (`data.errorSource: 'FetchNetworkErrorWrapper'`) with the runtime's rejection as `cause`, so its transport code survives — on the rejection itself under Bun (`ConnectionRefused`), on its `cause` under Node (`ECONNREFUSED`) — and the failure record carries the same `causeChain` field as `withRetry`'s retry record. |
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`. Each `Retry N/M for <operation>: <message> — waiting Xms` debug record adds `causeChain` to the context's `extra` when the retried error has a cause or a string `code` — `{ name, message, code? }` per node, the error itself first, so a transport code (`ECONNRESET`, `ConnectionRefused`) survives a retry that later succeeds; projections only, never a raw `Error`, a stack, or `McpError.data`. An error with neither logs exactly as before. `<message>`, and the exhausted error's message and name, are the error's own read as text: a Symbol reads `Symbol(…)`, a number its digits, and one that throws on read or is an object — or a thrown value that is an object but not an `Error` — `[Unreadable]`, so what was thrown never fails the retry. **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 per-attempt `Timeout` (`errorSource: 'FetchSignalTimeout'`) the clock's 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 — mid-attempt it rethrows the attempt's error unchanged, mid-backoff it rejects with `signal.reason` itself (an `AbortError` `DOMException` for a reason-less `abort()`), and the handler factory reports either as `RequestCancelled` when the request signal is the one that fired — 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`, `data.reason === 'pacer_shed'`, or `data.errorSource === 'FetchSignalTimeout'` (a caller-side deadline that already fired); 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
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; bounds **waiters only** — an arrival whose slot is open that instant starts without queueing, so `0` means "run when a slot is free, never wait"), `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, unless its slot opens that same instant. The shed error is `rateLimited` (-32003) with `data: { reason: 'pacer_shed', shedKind, retryAfter, queueDepth }`. `shedKind` (`PacerShedKind`) is `queue_full` (the call would wait behind `maxQueueDepth` waiters), `wait_projected` (the enqueue projection exceeds `maxWaitMs`), or `wait_elapsed` (`maxWaitMs` ran out while queued), and the message follows the kind — a `queue_full` shed names the full queue, not a wait budget. `retryAfter` is seconds until a caller joining behind every remaining waiter could start; while `maxConcurrent` is saturated — a release the projection cannot see — it is floored at the longest wait of any queued caller, the shed one included, minimum 1. `queueDepth` is the waiters still queued. **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 and the count untouched, and a shed (`reason: 'pacer_shed'`) from a pacer nested inside the task is local backpressure, never a gate closure. The first success resets the count, and so does a gate that has stood open for `maxMs`: the next rate limit starts over at `baseMs`, while one arriving sooner — the gate still closed included — keeps doubling, so continuous demand under a sustained limit keeps its capped backoff. **`pacer.cooldown`** samples the gate as `PacerCooldownState` `{ remainingMs, consecutive }`: `remainingMs` is the shared gate, not one rate limit's own computation (rate limits landing together close one gate at the later instant), so a task's rejection handler can report it on the server's own error — the pacer never writes to the task's error. Both stay 0 without `cooldown`. **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. 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. |
41
+ | `httpStatusToErrorCode` | `(status: number) -> JsonRpcErrorCode \| undefined` | Sync status → code lookup. Returns `undefined` for 1xx/2xx. A 3xx maps to `InvalidRequest` — it reaches error mapping when it is not followed (under `redirect: 'manual'`, or with no `Location`, a 304 included), 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
 
@@ -85,7 +85,7 @@ The `utils` export includes two type guards. The full set of guards lives in the
85
85
  | Export | API | Notes |
86
86
  |:-------|:----|:------|
87
87
  | `Logger` | Class | The `Logger` class itself. Use `Logger.getInstance()` if needed; most consumers use the `logger` singleton. |
88
- | `logger` | `Logger` instance (wraps Pino). `.debug(msg, ctx?)` `.info(msg, ctx?)` `.notice(msg, ctx?)` `.warning(msg, ctx?)` `.error(msg, errorOrCtx, ctx?)` `.crit(msg, errorOrCtx, ctx?)` `.alert(msg, errorOrCtx, ctx?)` `.emerg(msg, errorOrCtx, ctx?)` `.fatal(msg, errorOrCtx, ctx?)` `.isLevelEnabled(level) -> boolean` | Global structured logger. Use `ctx.log` in handlers instead. `logger` is for lifecycle/background contexts (startup, shutdown, `setup()`). Auto-redacts sensitive fields. The context's `extra` bag is flattened into the record, but a canonical field the context carries (`requestId`, `timestamp`, `traceId`, `spanId`, `sessionId`, `tenantId`, `operation`) always wins over an `extra` key of the same name. Records logged before the framework initializes the logger — anything in `setup()` — are held in a 250-record buffer and replayed once the sinks exist, filtered against the level the logger starts with. `isLevelEnabled(level)` is that filter: `true` when a record at `level` would be written, compared on the RFC 5424 order of all eight levels (a `notice` level drops `info` though pino emits both at `info`, a `crit` level drops `error`), and `true` for every level before initialization. The `ctx.log` mirror to the client is gated by the same check. **Note:** `.error()` and higher accept `(msg, Error, ctx?)` or `(msg, ctx?)` — the second arg is overloaded. `.fatal()` is an alias for `.emerg()`. Full RFC 5424 severity set. |
88
+ | `logger` | `Logger` instance (wraps Pino). `.debug(msg, ctx?)` `.info(msg, ctx?)` `.notice(msg, ctx?)` `.warning(msg, ctx?)` `.error(msg, errorOrCtx, ctx?)` `.crit(msg, errorOrCtx, ctx?)` `.alert(msg, errorOrCtx, ctx?)` `.emerg(msg, errorOrCtx, ctx?)` `.fatal(msg, errorOrCtx, ctx?)` `.isLevelEnabled(level) -> boolean` | Global structured logger. Use `ctx.log` in handlers instead. `logger` is for lifecycle/background contexts (startup, shutdown, `setup()`). Auto-redacts sensitive fields at every depth, matching any run of a key's adjacent words against the sensitive names (`x-api-key` and `accessToken` are redacted, `max_tokens` is not). An `Error` anywhere in the record, the `errorOrCtx` argument included, is written as `type`, `message`, `stack`, a string or `McpError` `code`, an `McpError`'s `data`, and `cause`/`errors` in the same shape — no other own property, and a cause's stack only when it differs from its parent's. Objects are kept through 15 levels: `'[MaxDepth]'` 16 levels down, `'[Circular]'` for a reference back to an enclosing object, `'[Truncated]'` where repeated content — an object reached again through a shared reference, or a string of 1,024+ characters written again — passes about 1,000,000 written characters a record, where one walk passes 400,000 reads, one per object, field, and array element, or where it passes 16 MiB (16,777,216 characters) of strings, field names, and primitives written, repeated or not (a 10 MB string is written whole, a 20 MB one is cut), `'[Unreadable]'` for a value whose read throws (so a log call never throws), and a class instance (`AbortSignal`, `Map`) dropped. The context's `extra` bag is flattened into the record, but a canonical field the context carries (`requestId`, `timestamp`, `traceId`, `spanId`, `sessionId`, `tenantId`, `operation`) always wins over an `extra` key of the same name, and is never redacted at the record root. An `extra` key named after a field the logger writes on the line itself (`level`, `time`, `msg`, `env`, `version`, `pid`, `hostname`, and `err` when an error argument is passed) is written as `data_<name>`, so the line keeps one of each, its own, and is routed by its own `level`. A context or `extra` the logger cannot read at all is written as `context: '[Unreadable]'` or `extra: '[Unreadable]'`. Records logged before the framework initializes the logger — anything in `setup()` — are held in a 250-record buffer and replayed once the sinks exist, filtered against the level the logger starts with. `isLevelEnabled(level)` is that filter: `true` when a record at `level` would be written, compared on the RFC 5424 order of all eight levels (a `notice` level drops `info` though pino emits both at `info`, a `crit` level drops `error`), and `true` for every level before initialization. The `ctx.log` mirror to the client is gated by the same check. **Note:** `.error()` and higher accept `(msg, Error, ctx?)` or `(msg, ctx?)` — the second arg is overloaded. `.fatal()` is an alias for `.emerg()`. Full RFC 5424 severity set. |
89
89
  | `McpLogLevel` | Type | Log level union type for typing level variables. |
90
90
 
91
91
  ---
@@ -109,7 +109,7 @@ The `utils` export includes two type guards. The full set of guards lives in the
109
109
 
110
110
  | Export | API | Notes |
111
111
  |:-------|:----|:------|
112
- | `ErrorHandler` | `.tryCatch<T>(fn, opts) -> Promise<T>` `.handleError(error, opts) -> Error` `.classifyOnly(error) -> { code, message, data? }` `.determineErrorCode(error) -> JsonRpcErrorCode` `.mapError(error, mappings, defaultFactory?) -> T \| Error` `.formatError(error) -> Record<string, unknown>` | Service-level error handling. `tryCatch` wraps async or sync `fn`, logs via `handleError`, and always rethrows. No `.tryCatchSync()`. Use in services, NOT in tool handlers (those throw raw `McpError`). `tryCatch` accepts `Omit<ErrorHandlerOptions, 'rethrow'>` — required: `operation`. Optional: `context`, `errorCode`, `input`, `includeStack`, `critical`, `errorMapper`. `handleError` accepts the full `ErrorHandlerOptions` including `rethrow`. The returned error's `data` (client-visible once thrown toward a handler) keeps the caught `McpError`'s own `data` plus `originalErrorName`/`originalMessage`/`rootCause`; `context` goes to the log record only. |
112
+ | `ErrorHandler` | `.tryCatch<T>(fn, opts) -> Promise<T>` `.handleError(error, opts) -> Error` `.classifyOnly(error) -> { code, message, data? }` `.determineErrorCode(error) -> JsonRpcErrorCode` `.mapError(error, mappings, defaultFactory?) -> T \| Error` `.formatError(error) -> Record<string, unknown>` | Service-level error handling. `tryCatch` wraps async or sync `fn`, logs via `handleError`, and always rethrows. No `.tryCatchSync()`. Use in services, NOT in tool handlers (those throw raw `McpError`). `tryCatch` accepts `Omit<ErrorHandlerOptions, 'rethrow'>` — required: `operation`. Optional: `context`, `errorCode`, `input`, `includeStack`, `critical`, `errorMapper`. `handleError` accepts the full `ErrorHandlerOptions` including `rethrow`. The returned error's `data` (client-visible once thrown toward a handler) keeps the caught `McpError`'s own `data` plus `originalErrorName`/`originalMessage`; the cause chain's `rootCause` and `context` go to the log record only. |
113
113
 
114
114
  ---
115
115
 
@@ -66,13 +66,15 @@ interface SanitizedPathInfo {
66
66
  - `sanitizeNumber`: `NaN`/`Infinity` always rejected; out-of-range values silently clamped with debug log
67
67
  - `sanitizeForLogging`: deep clones via `structuredClone`; returns `'[Log Sanitization Failed]'` on clone error
68
68
  - **Rejection reasons.** Every `ValidationError` carries `data.reason`, and a `data.recovery.hint` wherever the caller can change the input: `invalid_url` (`sanitizeUrl`; the hint names the allowed schemes), `invalid_path` / `path_traversal` / `absolute_path_disallowed` (`sanitizePath`), `invalid_json` / `json_too_large` (`sanitizeJson`; the latter names the byte cap), `invalid_number` (`sanitizeNumber`), and `unsupported_sanitize_context` (`sanitizeString`'s `'javascript'` context, which has no hint — it is a server-code choice)
69
- - `serializeForLogging`: `sanitizeForLogging`, then `JSON.stringify`, then a cut to at most `maxBytes` UTF-8 bytes on a character boundary — redaction first, so a cut never keeps part of a secret. A truncated `text` is a prefix of the whole serialization and no longer valid JSON; `truncated` says so. Returns a string so a deep payload survives the logger's four-level field depth. A value `JSON.stringify` rejects (a `bigint`) yields `'[Log Serialization Failed]'`. Backs the failed-call payload record (`LOG_TOOL_FAILURE_PAYLOADS`)
69
+ - `serializeForLogging`: `sanitizeForLogging`, then `JSON.stringify`, then a cut to at most `maxBytes` UTF-8 bytes on a character boundary — redaction first, so a cut never keeps part of a secret. A truncated `text` is a prefix of the whole serialization and no longer valid JSON; `truncated` says so. Returns a string so a payload deeper than the logger's 16-level field depth is written whole, not cut to `'[MaxDepth]'`. A value `JSON.stringify` rejects (a `bigint`) yields `'[Log Serialization Failed]'`. Backs the failed-call payload record (`LOG_TOOL_FAILURE_PAYLOADS`)
70
70
 
71
71
  ### Sensitive fields
72
72
 
73
73
  Pre-populated: `password`, `token`, `secret`, `apiKey`, `credential`, `jwt`, `ssn`, `cvv`, `authorization`, `cookie`, `clientsecret`, `client_secret`, `private_key`, `privatekey`.
74
74
 
75
- Manage with `setSensitiveFields(fields)` (merges, deduped, lowercased) and `getSensitiveFields()`. `getSensitivePinoFields()` generates 3-depth pino `redact.paths` patterns from the current list.
75
+ A key is sensitive when some run of its adjacent words, joined, equals one of these names, case and separators ignored. Words are split at every character other than a letter or digit, at a lowercase letter followed by a capital, at the end of a run of capitals, and around each run of digits, so `apiKey`, `API_KEY`, `APIKey`, `x-api-key`, `accessToken`, `upstream_private_key`, and `apiKey2` match, while `max_tokens`, `MAX_TOKENS`, `prompt_tokens`, and `tokenizer` do not. `sanitizeForLogging` and every log sink — the process log, `interactions.log`, the OTLP export, and the `ctx.log` mirror — use this one matcher.
76
+
77
+ Manage with `setSensitiveFields(fields)` (merges, deduped, lowercased; takes effect on every sink at once) and `getSensitiveFields()`. The correlation fields a log record's context supplies at its root — `requestId`, `sessionId`, `tenantId`, `traceId`, `spanId`, `timestamp`, `operation` — are never redacted. An added name matching one (`session_id`, `id`) still redacts a caller's own key of that name, and the same key on `interactions.log`, which carries no record context. `getSensitivePinoFields()` generates 3-depth pino `redact.paths` patterns from the current list, for a pino instance of your own; the framework logger does not use them.
76
78
 
77
79
  ### Usage
78
80