@cyanheads/mcp-ts-core 0.7.6 → 0.8.1

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 (107) hide show
  1. package/CLAUDE.md +22 -7
  2. package/README.md +2 -2
  3. package/changelog/0.8.x/0.8.0.md +33 -0
  4. package/changelog/0.8.x/0.8.1.md +17 -0
  5. package/changelog/template.md +13 -0
  6. package/dist/core/context.d.ts +67 -0
  7. package/dist/core/context.d.ts.map +1 -1
  8. package/dist/core/context.js +46 -1
  9. package/dist/core/context.js.map +1 -1
  10. package/dist/core/index.d.ts +2 -1
  11. package/dist/core/index.d.ts.map +1 -1
  12. package/dist/core/index.js +1 -0
  13. package/dist/core/index.js.map +1 -1
  14. package/dist/linter/rules/error-contract-rules.d.ts +45 -0
  15. package/dist/linter/rules/error-contract-rules.d.ts.map +1 -0
  16. package/dist/linter/rules/error-contract-rules.js +321 -0
  17. package/dist/linter/rules/error-contract-rules.js.map +1 -0
  18. package/dist/linter/rules/handler-body-rules.d.ts +18 -0
  19. package/dist/linter/rules/handler-body-rules.d.ts.map +1 -0
  20. package/dist/linter/rules/handler-body-rules.js +134 -0
  21. package/dist/linter/rules/handler-body-rules.js.map +1 -0
  22. package/dist/linter/rules/index.d.ts +2 -0
  23. package/dist/linter/rules/index.d.ts.map +1 -1
  24. package/dist/linter/rules/index.js +2 -0
  25. package/dist/linter/rules/index.js.map +1 -1
  26. package/dist/linter/rules/resource-rules.d.ts.map +1 -1
  27. package/dist/linter/rules/resource-rules.js +9 -0
  28. package/dist/linter/rules/resource-rules.js.map +1 -1
  29. package/dist/linter/rules/source-text.d.ts +19 -0
  30. package/dist/linter/rules/source-text.d.ts.map +1 -0
  31. package/dist/linter/rules/source-text.js +96 -0
  32. package/dist/linter/rules/source-text.js.map +1 -0
  33. package/dist/linter/rules/tool-rules.d.ts.map +1 -1
  34. package/dist/linter/rules/tool-rules.js +9 -0
  35. package/dist/linter/rules/tool-rules.js.map +1 -1
  36. package/dist/logs/combined.log +4 -4
  37. package/dist/logs/error.log +4 -4
  38. package/dist/mcp-server/apps/appBuilders.d.ts +9 -4
  39. package/dist/mcp-server/apps/appBuilders.d.ts.map +1 -1
  40. package/dist/mcp-server/apps/appBuilders.js +4 -0
  41. package/dist/mcp-server/apps/appBuilders.js.map +1 -1
  42. package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
  43. package/dist/mcp-server/resources/resource-registration.js +3 -2
  44. package/dist/mcp-server/resources/resource-registration.js.map +1 -1
  45. package/dist/mcp-server/resources/utils/resourceDefinition.d.ts +13 -5
  46. package/dist/mcp-server/resources/utils/resourceDefinition.d.ts.map +1 -1
  47. package/dist/mcp-server/resources/utils/resourceDefinition.js.map +1 -1
  48. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
  49. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +5 -4
  50. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
  51. package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
  52. package/dist/mcp-server/tools/tool-registration.js +13 -7
  53. package/dist/mcp-server/tools/tool-registration.js.map +1 -1
  54. package/dist/mcp-server/tools/utils/toolDefinition.d.ts +64 -16
  55. package/dist/mcp-server/tools/utils/toolDefinition.d.ts.map +1 -1
  56. package/dist/mcp-server/tools/utils/toolDefinition.js +25 -11
  57. package/dist/mcp-server/tools/utils/toolDefinition.js.map +1 -1
  58. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  59. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +6 -4
  60. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  61. package/dist/testing/index.d.ts +8 -0
  62. package/dist/testing/index.d.ts.map +1 -1
  63. package/dist/testing/index.js +5 -1
  64. package/dist/testing/index.js.map +1 -1
  65. package/dist/types-global/errors.d.ts +82 -0
  66. package/dist/types-global/errors.d.ts.map +1 -1
  67. package/dist/types-global/errors.js +25 -0
  68. package/dist/types-global/errors.js.map +1 -1
  69. package/dist/utils/formatting/index.d.ts +1 -0
  70. package/dist/utils/formatting/index.d.ts.map +1 -1
  71. package/dist/utils/formatting/index.js +1 -0
  72. package/dist/utils/formatting/index.js.map +1 -1
  73. package/dist/utils/formatting/partialResult.d.ts +145 -0
  74. package/dist/utils/formatting/partialResult.d.ts.map +1 -0
  75. package/dist/utils/formatting/partialResult.js +145 -0
  76. package/dist/utils/formatting/partialResult.js.map +1 -0
  77. package/dist/utils/index.d.ts +2 -1
  78. package/dist/utils/index.d.ts.map +1 -1
  79. package/dist/utils/index.js +2 -1
  80. package/dist/utils/index.js.map +1 -1
  81. package/dist/utils/network/httpError.d.ts +112 -0
  82. package/dist/utils/network/httpError.d.ts.map +1 -0
  83. package/dist/utils/network/httpError.js +153 -0
  84. package/dist/utils/network/httpError.js.map +1 -0
  85. package/dist/utils/network/retry.d.ts.map +1 -1
  86. package/dist/utils/network/retry.js +0 -1
  87. package/dist/utils/network/retry.js.map +1 -1
  88. package/package.json +5 -4
  89. package/scripts/split-changelog.ts +133 -0
  90. package/skills/add-app-tool/SKILL.md +12 -0
  91. package/skills/add-resource/SKILL.md +40 -0
  92. package/skills/add-service/SKILL.md +54 -1
  93. package/skills/add-test/SKILL.md +39 -0
  94. package/skills/add-tool/SKILL.md +42 -5
  95. package/skills/api-context/SKILL.md +75 -1
  96. package/skills/api-errors/SKILL.md +183 -5
  97. package/skills/api-linter/SKILL.md +223 -3
  98. package/skills/api-testing/SKILL.md +79 -4
  99. package/skills/api-utils/SKILL.md +4 -2
  100. package/skills/design-mcp-server/SKILL.md +13 -10
  101. package/skills/field-test/SKILL.md +81 -15
  102. package/skills/maintenance/SKILL.md +5 -2
  103. package/skills/report-issue-framework/SKILL.md +2 -2
  104. package/skills/security-pass/SKILL.md +6 -5
  105. package/templates/AGENTS.md +23 -8
  106. package/templates/CLAUDE.md +23 -8
  107. package/templates/changelog/template.md +18 -5
@@ -4,7 +4,7 @@ description: >
4
4
  Scaffold a new MCP tool definition. Use when the user asks to add a tool, create a new tool, or implement a new capability for the server.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.8"
7
+ version: "1.9"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -58,10 +58,16 @@ export const {{TOOL_EXPORT}} = tool('{{tool_name}}', {
58
58
  // All fields need .describe(). Only JSON-Schema-serializable Zod types allowed.
59
59
  }),
60
60
  // auth: ['tool:{{tool_name}}:read'],
61
+ // errors: [
62
+ // { reason: 'no_match', code: JsonRpcErrorCode.NotFound, when: 'No items matched the query.' },
63
+ // { reason: 'queue_full', code: JsonRpcErrorCode.RateLimited, when: 'Local queue at capacity.', retryable: true },
64
+ // ],
61
65
 
62
66
  async handler(input, ctx) {
63
67
  ctx.log.info('Processing', { /* relevant input fields */ });
64
- // Pure logic — throw on failure, no try/catch
68
+ // Pure logic — throw on failure, no try/catch.
69
+ // With an `errors[]` contract: `throw ctx.fail('reason_id', message?, data?)`.
70
+ // Without: throw via factories (`notFound`, `validationError`, …) or plain `Error`.
65
71
  return { /* output */ };
66
72
  },
67
73
 
@@ -233,9 +239,39 @@ format: (result) => [{
233
239
 
234
240
  ### Error classification and messaging
235
241
 
236
- The framework auto-classifies many errors at runtime (HTTP status codes, JS error types, common patterns). Use explicit error factories when you want a specific code and clear recovery guidance; plain `throw new Error()` when auto-classification is sufficient.
242
+ **Recommended: declare an `errors[]` contract.** A typed contract surfaces in `tools/list` and gives the handler a typed `ctx.fail(reason, …)` keyed by the declared reason union TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated and tamper-proof, and the linter enforces conformance against the handler body.
237
243
 
238
- **Classify by origin** — different sources need different codes:
244
+ ```typescript
245
+ import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
246
+
247
+ export const fetchArticles = tool('fetch_articles', {
248
+ description: 'Fetch articles by PMID.',
249
+ errors: [
250
+ { reason: 'no_pmid_match', code: JsonRpcErrorCode.NotFound,
251
+ when: 'None of the requested PMIDs returned data.' },
252
+ { reason: 'queue_full', code: JsonRpcErrorCode.RateLimited,
253
+ when: 'Local request queue at capacity.', retryable: true },
254
+ ],
255
+ input: z.object({ pmids: z.array(z.string()).describe('PMIDs to fetch') }),
256
+ output: z.object({ articles: z.array(ArticleSchema).describe('Resolved articles') }),
257
+ async handler(input, ctx) {
258
+ if (queue.full()) throw ctx.fail('queue_full');
259
+ const articles = await fetch(input.pmids);
260
+ if (articles.length === 0) {
261
+ throw ctx.fail('no_pmid_match', `No data for ${input.pmids.length} PMIDs`, { pmids: input.pmids });
262
+ }
263
+ return { articles };
264
+ },
265
+ });
266
+ ```
267
+
268
+ **Baseline codes** (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring. Omit the contract only for throwaway prototypes — declare it everywhere else. Wire-level behavior is identical when omitted, but you lose the type-checked `ctx.fail`, the `tools/list` advertisement, and conformance lint coverage.
269
+
270
+ `ctx.fail` accepts an optional 4th `options` argument for ES2022 cause chaining: `throw ctx.fail('upstream_error', 'Upstream returned 500', { url }, { cause: e })`.
271
+
272
+ **Service-thrown contract reasons.** When the throw happens in a called service rather than the handler itself, `ctx.fail` isn't reachable — services don't have `ctx`. Pass `data: { reason: 'X' }` to the factory in the service; the framework's auto-classifier preserves `data` on the wire, so the contract reason rides through unchanged. The handler bubbles the error without catching. See `add-service` for the pattern.
273
+
274
+ **Fallback: error factories.** Use when no contract entry fits — ad-hoc throws, prototype tools, or service-layer code. The framework also auto-classifies plain `throw new Error()` from message patterns as a last resort.
239
275
 
240
276
  ```typescript
241
277
  // Client input error — agent can fix and retry
@@ -259,7 +295,7 @@ throw invalidParams(
259
295
  );
260
296
  ```
261
297
 
262
- **Error messages are recovery instructions.** Name what went wrong, why, and what action to take. The message is the agent's only signal — a bare "Not found" is a dead end.
298
+ **Error messages are recovery instructions.** Name what went wrong, why, and what action to take. The message is the agent's only signal — a bare "Not found" is a dead end. See `skills/api-errors/SKILL.md` for the full contract pattern, factories list, and auto-classification table.
263
299
 
264
300
  ### Include operational metadata
265
301
 
@@ -329,6 +365,7 @@ Large payloads burn the agent's context window. Default to curated summaries; of
329
365
  - [ ] `format()` renders every field in the output schema — enforced at lint time via sentinel injection, startup fails with `format-parity` errors otherwise. Different clients forward different surfaces (Claude Code → `structuredContent`, Claude Desktop → `content[]`); both must carry the same data. Primary fix: render the missing field in `format()` (use `z.discriminatedUnion` for list/detail variants). Escape hatch: if the output schema was over-typed for a genuinely dynamic upstream API, relax it (`z.object({}).passthrough()`) rather than maintaining aspirational typing
330
366
  - [ ] If wrapping external API: output schema and `format()` preserve uncertainty from sparse upstream payloads instead of inventing concrete values
331
367
  - [ ] `auth` scopes declared if the tool needs authorization
368
+ - [ ] `errors: [...]` contract declared for known domain failure modes (recommended; omit only for throwaway prototypes)
332
369
  - [ ] `task: true` added if the tool is long-running
333
370
  - [ ] Registered in the project's existing `createApp()` tool list (directly or via barrel)
334
371
  - [ ] `bun run devcheck` passes
@@ -41,6 +41,10 @@ interface Context {
41
41
  readonly elicit?: (message: string, schema: z.ZodObject<z.ZodRawShape>) => Promise<ElicitResult>;
42
42
  readonly sample?: (messages: SamplingMessage[], opts?: SamplingOpts) => Promise<CreateMessageResult>;
43
43
 
44
+ // Resource notifications — present when transport supports them
45
+ readonly notifyResourceListChanged?: () => void;
46
+ readonly notifyResourceUpdated?: (uri: string) => void;
47
+
44
48
  // Cancellation
45
49
  readonly signal: AbortSignal;
46
50
 
@@ -52,6 +56,8 @@ interface Context {
52
56
  }
53
57
  ```
54
58
 
59
+ > **`ctx.fail` is on `HandlerContext<R>`, not `Context`.** When a definition declares `errors: [...]`, the handler receives `HandlerContext<R> = Context & { fail: TypedFail<R> }` — the typed `fail` lives on the intersection, not on bare `Context`. See [`ctx.fail`](#ctxfail) below.
60
+
55
61
  ### Identity fields
56
62
 
57
63
  | Field | Always present | Source |
@@ -223,7 +229,7 @@ if (ctx.sample) {
223
229
  interface SamplingOpts {
224
230
  includeContext?: 'none' | 'thisServer' | 'allServers';
225
231
  maxTokens?: number;
226
- modelPreferences?: Record<string, unknown>;
232
+ modelPreferences?: ModelPreferences;
227
233
  stopSequences?: string[];
228
234
  temperature?: number;
229
235
  }
@@ -320,6 +326,71 @@ Prefer `params` (the extracted URI template variables) over parsing `ctx.uri` ma
320
326
 
321
327
  ---
322
328
 
329
+ ## `ctx.fail`
330
+
331
+ Present only when the definition declares an `errors[]` contract. Builds an `McpError` keyed by the contract's `reason` union, so the resulting code is consistent with what the tool advertises in `tools/list`.
332
+
333
+ ```ts
334
+ export const fetchItems = tool('fetch_items', {
335
+ description: 'Fetch items by ID.',
336
+ errors: [
337
+ { reason: 'no_match', code: JsonRpcErrorCode.NotFound, when: 'No items matched' },
338
+ { reason: 'queue_full', code: JsonRpcErrorCode.RateLimited, when: 'Local queue at capacity', retryable: true },
339
+ ],
340
+ input: z.object({ ids: z.array(z.string()).describe('Item IDs') }),
341
+ output: z.object({ items: z.array(ItemSchema).describe('Resolved items') }),
342
+ async handler(input, ctx) {
343
+ if (queue.full()) throw ctx.fail('queue_full');
344
+ const items = await fetch(input.ids);
345
+ if (items.length === 0) throw ctx.fail('no_match', `No items match ${input.ids.length} IDs`, { ids: input.ids });
346
+ // ctx.fail('typo') ← TypeScript error: 'typo' isn't in the contract
347
+ return { items };
348
+ },
349
+ });
350
+ ```
351
+
352
+ ### Signature
353
+
354
+ ```ts
355
+ // TypedFail<R> — R is the union of declared `reason` strings, derived from the
356
+ // definition's `errors: [...]` const tuple via the framework's `ReasonOf<E>`.
357
+ ctx.fail(
358
+ reason: R, // union of declared reason strings
359
+ message?: string, // defaults to the contract entry's `when` text
360
+ data?: Record<string, unknown>, // merged into err.data; cannot override `reason`
361
+ options?: { cause?: unknown }, // ES2022 cause chain
362
+ ): McpError
363
+ ```
364
+
365
+ ### Behavior
366
+
367
+ | Aspect | Detail |
368
+ |:-------|:-------|
369
+ | Code resolution | `code` comes from the matching contract entry — never from the caller. The thrown `McpError.code` always equals what's advertised in `tools/list`. |
370
+ | Default message | When `message` is omitted, the contract entry's `when` text is used. |
371
+ | `data.reason` | Auto-populated from the contract entry. Caller-supplied `data.reason` **cannot** override it — the framework spreads caller data first and writes `reason` last so observers see a stable identifier. |
372
+ | Cause chains | Pass `{ cause: e }` to preserve the original error — `pino-pretty` and observability platforms render the chain automatically. |
373
+ | Unknown reason | If the type-system guard is bypassed (JS caller, stale contract), `ctx.fail` returns an `McpError(InternalError)` with `data.reason` and `data.declaredReasons` set so the bug is loud rather than silent. |
374
+
375
+ ### Without a contract
376
+
377
+ When the definition has no `errors[]` field, `ctx` is plain `Context` and `ctx.fail` is absent. Throw `McpError` directly (or via factory):
378
+
379
+ ```ts
380
+ import { notFound, rateLimited } from '@cyanheads/mcp-ts-core/errors';
381
+
382
+ async handler(input, ctx) {
383
+ if (queue.full()) throw rateLimited('Queue at capacity');
384
+ const items = await fetch(input.ids);
385
+ if (items.length === 0) throw notFound(`No items match ${input.ids.length} IDs`);
386
+ return { items };
387
+ }
388
+ ```
389
+
390
+ The contract is opt-in. See `skills/api-errors/SKILL.md` for the full type-driven pattern, lint rules, and baseline-codes guidance.
391
+
392
+ ---
393
+
323
394
  ## Quick reference
324
395
 
325
396
  | Property | Type | Present when |
@@ -335,5 +406,8 @@ Prefer `params` (the extracted URI template variables) over parsing `ctx.uri` ma
335
406
  | `ctx.signal` | `AbortSignal` | Always |
336
407
  | `ctx.elicit` | `function \| undefined` | Client supports elicitation |
337
408
  | `ctx.sample` | `function \| undefined` | Client supports sampling |
409
+ | `ctx.notifyResourceListChanged` | `function \| undefined` | Transport supports resource notifications |
410
+ | `ctx.notifyResourceUpdated` | `function \| undefined` | Transport supports resource notifications |
338
411
  | `ctx.progress` | `ContextProgress \| undefined` | Tool defined with `task: true` |
339
412
  | `ctx.uri` | `URL \| undefined` | Resource handlers only |
413
+ | `ctx.fail` | `(reason, msg?, data?, opts?) => McpError` | Definition declares `errors[]` contract |
@@ -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.0"
7
+ version: "1.1"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -22,9 +22,78 @@ import { ErrorHandler } from '@cyanheads/mcp-ts-core/utils';
22
22
 
23
23
  ---
24
24
 
25
- ## Error Factories (Preferred)
25
+ ## Type-Driven Error Contract (recommended)
26
26
 
27
- Shorter than `new McpError(...)` and self-documenting. All return `McpError` instances. All accept an optional `options` parameter for error chaining via `{ cause }`.
27
+ The recommended path for new tools and resources. Declare failure modes as a const tuple under `errors`; the reason union flows into the handler's `ctx.fail` and TypeScript enforces that you can only fail with a declared reason:
28
+
29
+ ```ts
30
+ import { tool, z } from '@cyanheads/mcp-ts-core';
31
+ import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
32
+
33
+ export const fetchTool = tool('fetch_articles', {
34
+ description: 'Fetch articles by PMID',
35
+ input: z.object({ pmids: z.array(z.string()).describe('PMIDs') }),
36
+ output: z.object({ articles: z.array(z.unknown()).describe('Articles') }),
37
+
38
+ errors: [
39
+ { reason: 'no_match', code: JsonRpcErrorCode.NotFound,
40
+ when: 'No requested PMID returned data' },
41
+ { reason: 'queue_full', code: JsonRpcErrorCode.RateLimited,
42
+ when: 'Local request queue is at capacity', retryable: true },
43
+ { reason: 'ncbi_down', code: JsonRpcErrorCode.ServiceUnavailable,
44
+ when: 'NCBI E-utilities unreachable after retries', retryable: true },
45
+ ],
46
+
47
+ async handler(input, ctx) {
48
+ const articles = await ncbi.fetch(input.pmids);
49
+ if (articles.length === 0) {
50
+ throw ctx.fail('no_match', `None of ${input.pmids.length} PMIDs returned data`);
51
+ }
52
+ // ctx.fail('typo') ← TypeScript error: 'typo' isn't in the contract
53
+ return { articles };
54
+ },
55
+ });
56
+ ```
57
+
58
+ **What you get:**
59
+
60
+ | Surface | Behavior |
61
+ |:--------|:---------|
62
+ | Compile time | `ctx.fail('typo')` is a TS error. Auto-completes declared reasons. |
63
+ | 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. |
64
+ | `tools/list` | Contract surfaced under `_meta['mcp-ts-core/errors']` so clients/agents can preview failure modes. |
65
+ | Lint (startup) | Each `code` validated against `JsonRpcErrorCode`. Reasons validated as snake_case + unique within contract. |
66
+ | Lint (conformance) | If the handler `throw new McpError(JsonRpcErrorCode.X)` outside `ctx.fail`, conformance check warns when X isn't declared. |
67
+
68
+ **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 + advertising.
69
+
70
+ > **Limits of the conformance lint.** The conformance and prefer-fail rules scan the handler's source text for `throw` statements. Errors thrown from called services (e.g. `await myService.fetch()` raising `RateLimited` internally) are invisible — the lint only sees what's lexically in the handler. Treat the contract as the *advertised* failure surface; bubbled-up codes still reach the client correctly via the auto-classifier, just without lint enforcement.
71
+
72
+ ### Carrying contract `reason` from services
73
+
74
+ Services don't have `ctx`, so they can't call `ctx.fail`. To make a service-thrown failure carry the contract's `reason` on the wire, **pass `data: { reason: 'X' }` to the factory**. The framework's auto-classifier preserves `data` unchanged, so clients see the same `error.data.reason` they'd see from `ctx.fail`:
75
+
76
+ ```ts
77
+ // my-service.ts
78
+ throw validationError('Expression cannot be empty.', { reason: 'empty_expression' });
79
+ throw serviceUnavailable('Upstream timeout', { reason: 'evaluation_timeout' });
80
+ ```
81
+
82
+ ```ts
83
+ // my-tool.tool.ts
84
+ errors: [
85
+ { reason: 'empty_expression', code: JsonRpcErrorCode.ValidationError, when: 'Input is empty.' },
86
+ { reason: 'evaluation_timeout', code: JsonRpcErrorCode.ServiceUnavailable, when: 'Upstream exceeded the configured timeout.' },
87
+ ]
88
+ ```
89
+
90
+ The handler doesn't catch and re-throw — letting service errors bubble unchanged keeps "logic throws, framework catches" intact. The contract still publishes in `tools/list`, the wire payload still carries `code` + `data.reason`, and clients can switch on reason without parsing message text. What's lost is lint-time enforcement that every reason is reachable; compensate with one wire-shape test per reason.
91
+
92
+ ---
93
+
94
+ ## Error Factories (fallback)
95
+
96
+ 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 }`.
28
97
 
29
98
  ```ts
30
99
  throw notFound('Item not found', { itemId: '123' });
@@ -50,6 +119,9 @@ throw serviceUnavailable('API call failed', { url }, { cause: error });
50
119
  | `timeout(msg, data?, options?)` | Timeout (-32004) |
51
120
  | `serviceUnavailable(msg, data?, options?)` | ServiceUnavailable (-32000) |
52
121
  | `configurationError(msg, data?, options?)` | ConfigurationError (-32008) |
122
+ | `internalError(msg, data?, options?)` | InternalError (-32603) |
123
+ | `serializationError(msg, data?, options?)` | SerializationError (-32070) — JSON/XML/parser failures |
124
+ | `databaseError(msg, data?, options?)` | DatabaseError (-32010) |
53
125
 
54
126
  `options` is `{ cause?: unknown }` — the standard ES2022 `ErrorOptions` type.
55
127
 
@@ -57,7 +129,7 @@ throw serviceUnavailable('API call failed', { url }, { cause: error });
57
129
 
58
130
  ## McpError Constructor
59
131
 
60
- For codes not covered by factories (InternalError, DatabaseError, etc.):
132
+ For codes not covered by factories (rare `MethodNotFound`, `ParseError`, `InitializationFailed`, `UnknownError`):
61
133
 
62
134
  ```ts
63
135
  throw new McpError(code, message?, data?, options?)
@@ -134,13 +206,15 @@ The framework applies these steps in order — first match wins:
134
206
  | Constructor | Mapped Code |
135
207
  |:------------|:------------|
136
208
  | `SyntaxError` | `ValidationError` |
137
- | `TypeError` | `ValidationError` |
138
209
  | `RangeError` | `ValidationError` |
139
210
  | `URIError` | `ValidationError` |
211
+ | `ZodError` | `ValidationError` |
140
212
  | `ReferenceError` | `InternalError` |
141
213
  | `EvalError` | `InternalError` |
142
214
  | `AggregateError` | `InternalError` |
143
215
 
216
+ `TypeError` is **intentionally excluded** from the constructor table — runtime `TypeError`s (e.g. *"Cannot read property X of undefined"*) are programmer errors, not validation failures. They fall through to message-pattern matching, then to the `InternalError` fallback.
217
+
144
218
  ### Common Message Patterns
145
219
 
146
220
  Patterns are tested against both the error `message` and `name`, case-insensitively. First match wins.
@@ -254,3 +328,107 @@ const parsed = await ErrorHandler.tryCatch(
254
328
  | `critical` | `boolean` | No | Marks the error as critical in logs (default `false`) |
255
329
  | `includeStack` | `boolean` | No | Include stack trace in log output (default `true`) |
256
330
  | `errorMapper` | `(error: unknown) => Error` | No | Custom transform applied instead of default `McpError` wrapping |
331
+
332
+ ---
333
+
334
+ ## HTTP Response → McpError
335
+
336
+ When you bypass `fetchWithTimeout` and use raw `fetch` (typically because you need granular code classification or response body access), use `httpErrorFromResponse` instead of writing your own status mapping ladder:
337
+
338
+ ```ts
339
+ import { httpErrorFromResponse } from '@cyanheads/mcp-ts-core/utils';
340
+
341
+ const response = await fetch(url, { signal: ctx.signal });
342
+ if (!response.ok) {
343
+ throw await httpErrorFromResponse(response, {
344
+ service: 'NCBI', // included in message
345
+ data: { endpoint, requestId: ctx.requestId },
346
+ });
347
+ }
348
+ ```
349
+
350
+ Captures the response body (truncated, configurable limit) and `Retry-After` header (stored as `data.retryAfter`) into `error.data`. The codes it produces line up with `withRetry`'s transient-code set, so retryable responses are retried automatically.
351
+
352
+ > **Body reaches the client.** `error.data` is forwarded to the MCP client as `_meta.error.data` (tool errors) or JSON-RPC `error.data` (resource errors). Upstream 401/403/422 responses sometimes echo token claims, internal user IDs, or schema validation hints — that text becomes client-visible. For sensitive endpoints, pass `captureBody: false` (or `bodyLimit: 0`) so the body stays out of `data`. Defaults remain `captureBody: true` because most upstreams return useful diagnostic text and silent dropping helps no one debug.
353
+
354
+ Full status table:
355
+
356
+ | Status | Code |
357
+ |:-------|:-----|
358
+ | 400 | `InvalidParams` |
359
+ | 401 | `Unauthorized` |
360
+ | 402, 403 | `Forbidden` |
361
+ | 404 | `NotFound` |
362
+ | 408, 425, 504 | `Timeout` |
363
+ | 409, 423, 424 | `Conflict` |
364
+ | 422 | `ValidationError` |
365
+ | 429 | `RateLimited` |
366
+ | 405, 406, 410, 412, 415, 416, 417, 428, 431, 451, 4xx (other) | `InvalidRequest` |
367
+ | 500, 501 | `InternalError` |
368
+ | 502, 503, 5xx (other) | `ServiceUnavailable` |
369
+
370
+ Also exports `httpStatusToErrorCode(status)` for sync mapping when you don't have a Response object.
371
+
372
+ ---
373
+
374
+ ## Handler-Body Lint Rules
375
+
376
+ The startup linter (`bun run lint:mcp` and `createApp()` startup) checks handler bodies for common anti-patterns. All emit warnings (not errors) — they don't block startup but show up in `devcheck` output.
377
+
378
+ | Rule | Catches |
379
+ |:-----|:--------|
380
+ | `prefer-mcp-error-in-handler` | `throw new Error(...)` inside a handler — use `McpError` or a factory so the framework returns a specific code |
381
+ | `prefer-error-factory` | `new McpError(JsonRpcErrorCode.NotFound, ...)` when `notFound(...)` exists |
382
+ | `preserve-cause-on-rethrow` | `catch (e) { throw new McpError(...) }` without `{ cause: e }` |
383
+ | `no-stringify-upstream-error` | `JSON.stringify(...)` inside a thrown message — risks leaking internal traces; use `data` payload instead |
384
+
385
+ ---
386
+
387
+ ## Error Contract Lint Rules
388
+
389
+ The linter validates the structure of `errors[]` and (when present) cross-checks the handler body against the declared contract.
390
+
391
+ ### Structural rules
392
+
393
+ | Rule | Severity | Catches |
394
+ |:-----|:---------|:--------|
395
+ | `error-contract-type` | error | `errors` is present but not an array |
396
+ | `error-contract-empty` | warning | `errors: []` — drop the field instead, or declare actual failure modes |
397
+ | `error-contract-entry-type` | error | An entry isn't an object |
398
+ | `error-contract-code-type` | error | `code` missing or not a number |
399
+ | `error-contract-code-unknown` | error | `code` isn't a real `JsonRpcErrorCode` value |
400
+ | `error-contract-code-unknown-error` | warning | `code` is `JsonRpcErrorCode.UnknownError` (the giveup-fallback — pick a more specific code) |
401
+ | `error-contract-reason-required` | error | `reason` missing or empty |
402
+ | `error-contract-reason-format` | warning | `reason` not snake_case |
403
+ | `error-contract-reason-unique` | error | Duplicate `reason` within one contract |
404
+ | `error-contract-when-required` | error | `when` missing or empty |
405
+ | `error-contract-retryable-type` | warning | `retryable` is present but not a boolean |
406
+
407
+ ### Conformance rules
408
+
409
+ | Rule | Severity | Catches |
410
+ |:-----|:---------|:--------|
411
+ | `error-contract-conformance` | warning | Handler throws a non-baseline code that isn't in the contract. Suggests adding it to `errors[]` so `tools/list` advertises the failure mode. |
412
+ | `error-contract-prefer-fail` | warning | Handler throws a code that **is** in the contract directly (via factory or `new McpError`) instead of through `ctx.fail(reason, …)`. Encourages routing through the typed helper so observers see consistent `data.reason` values. |
413
+
414
+ ### Baseline codes (auto-allowed)
415
+
416
+ These codes bubble up from anywhere — services, framework utilities, the auto-classifier — and are implicitly always-possible on any tool. They're skipped by the conformance check, so the contract can stay focused on intentional domain failures:
417
+
418
+ - `InternalError` — bug, programmer error, truly unexpected
419
+ - `ServiceUnavailable` — upstream/network failures
420
+ - `Timeout` — request deadline exceeded, abort
421
+ - `ValidationError` — schema violations, malformed input
422
+ - `SerializationError` — JSON/XML parse failures
423
+
424
+ If you *want* to advertise one of these as a domain-specific failure (e.g., a tool that intentionally times out under defined conditions), declare it in `errors[]` anyway — the contract still surfaces in `tools/list`. The lint just doesn't *require* you to.
425
+
426
+ ### When to declare vs. let it bubble
427
+
428
+ The contract describes the **public failure surface** — the failures clients/agents can plan around. Modeled after how OpenAPI-driven frameworks treat 5xx: enumerated 4xx for intentional failures, implicit 5xx for infrastructure.
429
+
430
+ | Pattern | Use for |
431
+ |:--------|:--------|
432
+ | `throw ctx.fail('reason', …)` | Declared domain failures — typed, contract-checked, `data.reason` populated |
433
+ | `throw notFound(…)` / factories | Errors not in the contract; the auto-classifier handles them. Prefer `ctx.fail` when a matching contract entry exists. |
434
+ | Bubble up from services | Upstream classification already produced an `McpError` — don't re-wrap |