@cyanheads/mcp-ts-core 0.8.3 → 0.8.5

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 (37) hide show
  1. package/CLAUDE.md +26 -8
  2. package/README.md +2 -2
  3. package/changelog/0.8.x/0.8.4.md +60 -0
  4. package/changelog/0.8.x/0.8.5.md +42 -0
  5. package/dist/core/context.d.ts +79 -8
  6. package/dist/core/context.d.ts.map +1 -1
  7. package/dist/core/context.js +46 -9
  8. package/dist/core/context.js.map +1 -1
  9. package/dist/core/index.d.ts +2 -2
  10. package/dist/core/index.d.ts.map +1 -1
  11. package/dist/core/index.js +1 -1
  12. package/dist/core/index.js.map +1 -1
  13. package/dist/linter/rules/error-contract-rules.d.ts +3 -1
  14. package/dist/linter/rules/error-contract-rules.d.ts.map +1 -1
  15. package/dist/linter/rules/error-contract-rules.js +40 -2
  16. package/dist/linter/rules/error-contract-rules.js.map +1 -1
  17. package/dist/logs/combined.log +4 -12
  18. package/dist/logs/error.log +4 -12
  19. package/dist/mcp-server/tools/utils/toolDefinition.d.ts +5 -2
  20. package/dist/mcp-server/tools/utils/toolDefinition.d.ts.map +1 -1
  21. package/dist/mcp-server/tools/utils/toolDefinition.js +5 -2
  22. package/dist/mcp-server/tools/utils/toolDefinition.js.map +1 -1
  23. package/dist/testing/index.d.ts.map +1 -1
  24. package/dist/testing/index.js +5 -1
  25. package/dist/testing/index.js.map +1 -1
  26. package/dist/types-global/errors.d.ts +24 -3
  27. package/dist/types-global/errors.d.ts.map +1 -1
  28. package/package.json +11 -11
  29. package/skills/add-service/SKILL.md +13 -2
  30. package/skills/add-tool/SKILL.md +68 -23
  31. package/skills/api-auth/SKILL.md +6 -5
  32. package/skills/api-context/SKILL.md +55 -6
  33. package/skills/api-errors/SKILL.md +79 -7
  34. package/skills/design-mcp-server/SKILL.md +10 -3
  35. package/templates/AGENTS.md +4 -2
  36. package/templates/CLAUDE.md +4 -2
  37. package/templates/src/mcp-server/tools/definitions/echo.tool.ts +1 -0
@@ -4,7 +4,7 @@ description: >
4
4
  Scaffold a new MCP tool definition. Use when the user asks to add a tool, create a new tool, or implement a new capability for the server.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.0"
7
+ version: "2.4"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -65,9 +65,19 @@ export const {{TOOL_EXPORT}} = tool('{{tool_name}}', {
65
65
  // (InternalError, ServiceUnavailable, Timeout, ValidationError,
66
66
  // SerializationError) bubble freely — only declare domain-specific reasons.
67
67
  // Delete this block if no domain failures apply.
68
+ //
69
+ // `recovery` is required (≥ 5 words) — it's the agent's next move when this
70
+ // failure fires. Forcing function for thoughtful guidance: placeholders like
71
+ // "Try again." get flagged by the linter. The contract `recovery` is the
72
+ // single source of truth for what flows to the wire — opt in at the throw
73
+ // site by spreading `ctx.recoveryFor('reason')` into the `data` arg.
68
74
  errors: [
69
- { reason: 'no_match', code: JsonRpcErrorCode.NotFound, when: 'No items matched the query.' },
70
- { reason: 'queue_full', code: JsonRpcErrorCode.RateLimited, when: 'Local queue at capacity.', retryable: true },
75
+ { reason: 'no_match', code: JsonRpcErrorCode.NotFound,
76
+ when: 'No items matched the query.',
77
+ recovery: 'Broaden the query or check the spelling and try again.' },
78
+ { reason: 'queue_full', code: JsonRpcErrorCode.RateLimited,
79
+ when: 'Local queue at capacity.', retryable: true,
80
+ recovery: 'Wait a few seconds before retrying or reduce batch size.' },
71
81
  ],
72
82
 
73
83
  async handler(input, ctx) {
@@ -76,7 +86,18 @@ export const {{TOOL_EXPORT}} = tool('{{tool_name}}', {
76
86
  // With an `errors[]` contract: `throw ctx.fail('reason_id', message?, data?)`.
77
87
  // Without: throw via factories (`notFound`, `validationError`, …) or plain `Error`.
78
88
  const items = await search(input);
79
- if (items.length === 0) throw ctx.fail('no_match', `No items matched "${input.query}"`);
89
+ if (items.length === 0) {
90
+ // Dynamic recovery — interpolate runtime context, override the contract default.
91
+ throw ctx.fail('no_match', `No items matched "${input.query}"`, {
92
+ recovery: { hint: `Try a broader query than "${input.query}", or check the spelling.` },
93
+ });
94
+ }
95
+ if (queue.full()) {
96
+ // Static recovery — resolve from the contract via ctx.recoveryFor('reason').
97
+ // Single source of truth: the string lives in errors[] above; this spread
98
+ // pulls it onto the wire so format()-only clients see the recovery hint.
99
+ throw ctx.fail('queue_full', undefined, { ...ctx.recoveryFor('queue_full') });
100
+ }
80
101
  return { items };
81
102
  },
82
103
 
@@ -277,46 +298,59 @@ export const fetchArticles = tool('fetch_articles', {
277
298
  description: 'Fetch articles by PMID.',
278
299
  errors: [
279
300
  { reason: 'no_pmid_match', code: JsonRpcErrorCode.NotFound,
280
- when: 'None of the requested PMIDs returned data.' },
301
+ when: 'None of the requested PMIDs returned data.',
302
+ recovery: 'Try pubmed_search_articles to discover valid PMIDs first.' },
281
303
  { reason: 'queue_full', code: JsonRpcErrorCode.RateLimited,
282
- when: 'Local request queue at capacity.', retryable: true },
304
+ when: 'Local request queue at capacity.', retryable: true,
305
+ recovery: 'Wait 30 seconds and retry, or reduce batch size.' },
283
306
  ],
284
307
  input: z.object({ pmids: z.array(z.string()).describe('PMIDs to fetch') }),
285
308
  output: z.object({ articles: z.array(ArticleSchema).describe('Resolved articles') }),
286
309
  async handler(input, ctx) {
287
- if (queue.full()) throw ctx.fail('queue_full');
310
+ // Static recovery ctx.recoveryFor pulls the contract recovery onto the wire.
311
+ // The contract is the single source of truth; this spread surfaces it on the
312
+ // wire so format()-only clients see the hint mirrored into content[] text.
313
+ if (queue.full()) throw ctx.fail('queue_full', undefined, { ...ctx.recoveryFor('queue_full') });
314
+
288
315
  const articles = await fetch(input.pmids);
289
316
  if (articles.length === 0) {
290
- throw ctx.fail('no_pmid_match', `No data for ${input.pmids.length} PMIDs`, { pmids: input.pmids });
317
+ // Dynamic recovery interpolate runtime context, override the contract default.
318
+ throw ctx.fail('no_pmid_match', `No data for ${input.pmids.length} PMIDs`, {
319
+ pmids: input.pmids,
320
+ recovery: { hint: `Use pubmed_search_articles to discover valid PMIDs.` },
321
+ });
291
322
  }
292
323
  return { articles };
293
324
  },
294
325
  });
295
326
  ```
296
327
 
328
+ **`ctx.recoveryFor(reason)`** resolves the contract's `recovery` string into the wire shape `{ recovery: { hint } }` — safe to spread into `data` so format()-only clients see the same recovery hint that structuredContent clients read. Always available on `Context` (no-op `{}` when no contract), strictly typed on `HandlerContext<R>` against the declared reasons. Use it for static recovery; pass `{ recovery: { hint: \`…${dynamic}…\` } }` directly when you need runtime context. The contract is the single source of truth — write the recovery once, lint validates it ≥5 words, the resolver carries it to every throw site.
329
+
297
330
  **Baseline codes** (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring. Wire-level behavior is identical when the contract is omitted, but you lose the type-checked `ctx.fail`, the `tools/list` advertisement, and conformance lint coverage — declare a contract whenever the tool has a domain-specific failure mode.
298
331
 
299
332
  `ctx.fail` accepts an optional 4th `options` argument for ES2022 cause chaining: `throw ctx.fail('upstream_error', 'Upstream returned 500', { url }, { cause: e })`.
300
333
 
301
334
  #### Service-layer throws
302
335
 
303
- API-wrapping tools usually delegate to a service: `const data = await ncbi.fetch(input)`. The throw lives in the service, not the handler and services don't receive `ctx`, so `ctx.fail` is unreachable from there. The fix is to pass `data: { reason: 'X' }` to the factory in the service. The framework's auto-classifier preserves `data` on the wire, so clients see the same `error.data.reason` they would have seen from `ctx.fail`. The handler doesn't catch — it just bubbles.
336
+ API-wrapping tools usually delegate to a service: `await ncbi.fetch(input, ctx)`. The throw lives in the service, not the handler. Services accept `ctx` (the unified Context) so they can call `ctx.log`, `ctx.recoveryFor`, etc. The handler doesn't catch it just bubbles, and the framework's auto-classifier preserves `data` on the wire.
304
337
 
305
- The contract entry on the tool and the `data: { reason }` on the service throw need to use the **same reason string** so the two sides line up.
338
+ The contract entry on the tool and the `data: { reason }` on the service throw need to use the **same reason string** so the two sides line up. `ctx.recoveryFor('reason')` resolves the contract recovery from the calling tool's `errors[]` — same single-source-of-truth pattern that works in handlers.
306
339
 
307
340
  ```typescript
308
- // service — passes data.reason to match the consuming tool's contract
341
+ // service — receives ctx; passes data.reason and spreads ctx.recoveryFor
342
+ import type { Context } from '@cyanheads/mcp-ts-core';
309
343
  import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
310
344
 
311
345
  export class NcbiService {
312
- async fetch(pmids: string[]) {
346
+ async fetch(pmids: string[], ctx: Context) {
313
347
  const response = await fetchWithRetry(...);
314
348
  if (!response.ok) {
315
- throw serviceUnavailable(
316
- `NCBI returned HTTP ${response.status}`,
317
- { reason: 'ncbi_unreachable', status: response.status }, // ← matches contract entry
318
- { cause: undefined },
319
- );
349
+ throw serviceUnavailable(`NCBI returned HTTP ${response.status}`, {
350
+ reason: 'ncbi_unreachable',
351
+ status: response.status,
352
+ ...ctx.recoveryFor('ncbi_unreachable'), // resolves from caller's contract
353
+ });
320
354
  }
321
355
  return response.json();
322
356
  }
@@ -326,14 +360,17 @@ export class NcbiService {
326
360
  export const fetchArticles = tool('fetch_articles', {
327
361
  errors: [
328
362
  { reason: 'ncbi_unreachable', code: JsonRpcErrorCode.ServiceUnavailable,
329
- when: 'NCBI E-utilities is unreachable.', retryable: true },
363
+ when: 'NCBI E-utilities is unreachable.', retryable: true,
364
+ recovery: 'NCBI is degraded; retry in a few minutes.' },
330
365
  ],
331
366
  async handler(input, ctx) {
332
- return { articles: await ncbi.fetch(input.pmids) }; // throws bubble unchanged
367
+ return { articles: await ncbi.fetch(input.pmids, ctx) }; // throws bubble unchanged
333
368
  },
334
369
  });
335
370
  ```
336
371
 
372
+ `ctx.recoveryFor` returns `{}` when the calling tool has no contract or the reason isn't declared, so the spread is always safe — services don't have to know which tool called them.
373
+
337
374
  See `add-service` for the full pattern.
338
375
 
339
376
  #### Ad-hoc factory throws (fallback)
@@ -354,15 +391,23 @@ throw notFound(
354
391
  import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
355
392
  throw serviceUnavailable(`arXiv API returned HTTP ${status}. Retry in a few seconds.`);
356
393
 
357
- // Structured hint for programmatic recovery
394
+ // Recovery hint via the canonical `data.recovery.hint` shape — the framework
395
+ // auto-mirrors it into the content[] text as `Recovery: <hint>`, so format()-only
396
+ // clients (Claude Desktop) see the same guidance that structuredContent clients
397
+ // (Claude Code) read from `error.data.recovery.hint`. Other `data` keys reach
398
+ // structuredContent only.
358
399
  import { invalidParams } from '@cyanheads/mcp-ts-core/errors';
359
400
  throw invalidParams(
360
- `Date range exceeds 90-day API limit. Narrow the range or split into multiple queries.`,
361
- { maxDays: 90, requestedDays: daysBetween },
401
+ `Date range exceeds 90-day API limit.`,
402
+ {
403
+ maxDays: 90,
404
+ requestedDays: daysBetween,
405
+ recovery: { hint: 'Narrow the range or split into multiple queries.' },
406
+ },
362
407
  );
363
408
  ```
364
409
 
365
- **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.
410
+ **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, auto-classification table, and error-path parity (how `data.recovery.hint` reaches both client surfaces).
366
411
 
367
412
  ### Include operational metadata
368
413
 
@@ -121,10 +121,11 @@ Set via `MCP_AUTH_MODE` environment variable.
121
121
 
122
122
  ### tenantId sources
123
123
 
124
- | Transport | Source | Value |
125
- |:----------|:-------|:------|
126
- | HTTP with auth | JWT `tid` claim | Auto-propagated from token |
127
- | Stdio | Hardcoded default | `'default'` |
124
+ | Mode | Source | Value |
125
+ |:-----|:-------|:------|
126
+ | Stdio (any auth mode) | Hardcoded default | `'default'` |
127
+ | HTTP + `MCP_AUTH_MODE=none` | Hardcoded default | `'default'` (single-tenant by design) |
128
+ | HTTP + `MCP_AUTH_MODE=jwt`/`oauth` | JWT `tid` claim | Auto-propagated from token; `undefined` if absent (fail-closed) |
128
129
 
129
130
  ### Tenant ID validation rules
130
131
 
@@ -148,7 +149,7 @@ handler: async (input, ctx) => {
148
149
  },
149
150
  ```
150
151
 
151
- `ctx.state` throws `McpError(InvalidRequest)` if `tenantId` is missing. In stdio mode, `tenantId` defaults to `'default'` so `ctx.state` works without auth.
152
+ `ctx.state` throws `McpError(InvalidRequest)` if `tenantId` is missing. Stdio (any auth mode) and HTTP+`MCP_AUTH_MODE=none` default `tenantId` to `'default'` so `ctx.state` works without forcing operators to mint tokens. HTTP+`jwt`/`oauth` deliberately fails closed when the token lacks a `tid` claim — distinct authenticated callers must not silently share state.
152
153
 
153
154
  ---
154
155
 
@@ -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, all sub-APIs (`ctx.log`, `ctx.state`, `ctx.elicit`, `ctx.sample`, `ctx.progress`), and when to use each.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.1"
7
+ version: "1.2"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -26,7 +26,7 @@ interface Context {
26
26
  // Identity & tracing
27
27
  readonly requestId: string; // Unique per request, auto-generated
28
28
  readonly timestamp: string; // ISO 8601 request start time
29
- readonly tenantId?: string; // From JWT 'tid' claim; 'default' in stdio mode
29
+ readonly tenantId?: string; // JWT 'tid' claim; 'default' for stdio and HTTP+MCP_AUTH_MODE=none
30
30
  readonly traceId?: string; // OTEL trace ID (present when OTEL enabled)
31
31
  readonly spanId?: string; // OTEL span ID (present when OTEL enabled)
32
32
  readonly auth?: AuthContext; // Parsed auth claims (clientId, scopes, sub)
@@ -53,10 +53,14 @@ interface Context {
53
53
 
54
54
  // Raw URI — present only for resource handlers
55
55
  readonly uri?: URL;
56
+
57
+ // Opt-in contract resolver — always present (returns {} when no contract is attached
58
+ // or the reason is unknown), strictly typed on HandlerContext<R> against declared reasons.
59
+ recoveryFor(reason: string): { recovery: { hint: string } } | {};
56
60
  }
57
61
  ```
58
62
 
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.
63
+ > **`ctx.fail` is on `HandlerContext<R>`, not `Context`.** When a definition declares `errors: [...]`, the handler receives `HandlerContext<R> = Context & { fail: TypedFail<R>; recoveryFor: TypedRecoveryFor<R> }` — both the typed `fail` and the strictly-typed `recoveryFor` live on the intersection. The bare `Context.recoveryFor` is the loose, always-present resolver. See [`ctx.fail`](#ctxfail) and [`ctx.recoveryFor`](#ctxrecoveryfor) below.
60
64
 
61
65
  ### Identity fields
62
66
 
@@ -64,7 +68,7 @@ interface Context {
64
68
  |:------|:--------------|:-------|
65
69
  | `requestId` | Yes | Auto-generated UUID per request |
66
70
  | `timestamp` | Yes | ISO 8601, request start |
67
- | `tenantId` | In stdio (as `'default'`); from JWT `tid` claim in HTTP | JWT / stdio default |
71
+ | `tenantId` | Stdio and HTTP+`MCP_AUTH_MODE=none` (as `'default'`); JWT `tid` claim in HTTP+`jwt`/`oauth` | JWT / single-tenant default |
68
72
  | `traceId` | When OTEL enabled | OTEL trace context |
69
73
  | `spanId` | When OTEL enabled | OTEL trace context |
70
74
  | `auth` | When auth enabled | Parsed JWT claims |
@@ -158,7 +162,7 @@ if (page.cursor) { /* more pages available */ }
158
162
 
159
163
  ### Behavior notes
160
164
 
161
- - Throws `McpError(InvalidRequest)` if `tenantId` is missing (won't happen in stdio mode — defaults to `'default'`).
165
+ - Throws `McpError(InvalidRequest)` if `tenantId` is missing. Won't happen in stdio (any auth mode) or HTTP+`MCP_AUTH_MODE=none` both default to `'default'`. Can happen in HTTP+`MCP_AUTH_MODE=jwt`/`oauth` when the token lacks a `tid` claim (intentional fail-closed: distinct authenticated callers must not silently share state).
162
166
  - Keys are tenant-prefixed internally; handlers never need to namespace manually.
163
167
  - **Workers persistence:** The `in-memory` provider loses data on cold starts. Use `cloudflare-kv`, `cloudflare-r2`, or `cloudflare-d1` for durable storage in Workers.
164
168
 
@@ -391,13 +395,57 @@ The contract is opt-in. See `skills/api-errors/SKILL.md` for the full type-drive
391
395
 
392
396
  ---
393
397
 
398
+ ## `ctx.recoveryFor`
399
+
400
+ Always present on `Context`. Resolves the contract `recovery` for a given reason and returns the canonical wire shape `{ recovery: { hint } }`, ready to spread into `data`. The first member of a planned **family of opt-in resolution helpers** (future: `troubleshootingFor`, `userMessageFor`, …).
401
+
402
+ ```ts
403
+ async handler(input, ctx) {
404
+ // Static recovery — pulled from the contract entry, no string duplication.
405
+ if (queue.full()) throw ctx.fail('queue_full', undefined, { ...ctx.recoveryFor('queue_full') });
406
+
407
+ // Dynamic recovery — interpolate runtime context, override the contract default.
408
+ if (!matched) throw ctx.fail('no_match', `No items for "${input.query}"`, {
409
+ recovery: { hint: `Try a broader query than "${input.query}", or check spelling.` },
410
+ });
411
+ }
412
+ ```
413
+
414
+ ### Signature
415
+
416
+ ```ts
417
+ // Loose (always present on Context — works without a contract attached):
418
+ ctx.recoveryFor(reason: string): { recovery: { hint: string } } | {}
419
+
420
+ // Strict (HandlerContext<R> when the definition declares errors[]):
421
+ ctx.recoveryFor(reason: R): { recovery: { hint: string } }
422
+ ```
423
+
424
+ ### Behavior
425
+
426
+ | Aspect | Detail |
427
+ |:-------|:-------|
428
+ | No contract attached | Returns `{}` — spread is a no-op. Always safe. |
429
+ | Unknown reason | Returns `{}` (TS prevents this for typed callers; runtime is loose for JS / stale contracts). |
430
+ | Declared reason | Returns `{ recovery: { hint: <contract.recovery> } }` — spread into `data`. |
431
+ | Override | Caller can override by spreading `recoveryFor` first then writing `recovery: { hint: '...' }` after — last write wins. |
432
+ | Service usage | Services that accept `ctx: Context` can spread `ctx.recoveryFor('reason')` directly; the no-op fallback means they don't need to know which tool called them. |
433
+
434
+ ### Why opt-in resolution, not auto-population
435
+
436
+ The framework never injects `data.recovery.hint` without an explicit signal at the throw site. Authors opt in by typing `ctx.recoveryFor('reason')` — the same way `ctx.fail('reason')` opts into resolving the contract `code`. The contract is the single source of truth for the recovery hint; the resolver is a typed lookup keyed by the same reason the author already typed. No magic, no hidden transformation.
437
+
438
+ The `≥5 words` lint rule on contract `recovery` (validated at lint time) makes this load-bearing — every `ctx.recoveryFor` call site benefits from the thoughtfulness the contract enforced.
439
+
440
+ ---
441
+
394
442
  ## Quick reference
395
443
 
396
444
  | Property | Type | Present when |
397
445
  |:---------|:-----|:-------------|
398
446
  | `ctx.requestId` | `string` | Always |
399
447
  | `ctx.timestamp` | `string` | Always |
400
- | `ctx.tenantId` | `string \| undefined` | Always in stdio (`'default'`); HTTP with auth |
448
+ | `ctx.tenantId` | `string \| undefined` | Stdio (`'default'`); HTTP+`MCP_AUTH_MODE=none` (`'default'`); HTTP+`jwt`/`oauth` (JWT `tid` claim — undefined if absent) |
401
449
  | `ctx.traceId` | `string \| undefined` | OTEL enabled |
402
450
  | `ctx.spanId` | `string \| undefined` | OTEL enabled |
403
451
  | `ctx.auth` | `AuthContext \| undefined` | Auth enabled |
@@ -411,3 +459,4 @@ The contract is opt-in. See `skills/api-errors/SKILL.md` for the full type-drive
411
459
  | `ctx.progress` | `ContextProgress \| undefined` | Tool defined with `task: true` |
412
460
  | `ctx.uri` | `URL \| undefined` | Resource handlers only |
413
461
  | `ctx.fail` | `(reason, msg?, data?, opts?) => McpError` | Definition declares `errors[]` contract |
462
+ | `ctx.recoveryFor` | `(reason) => { recovery: { hint } } \| {}` | Always (no-op when no contract); strictly typed on `HandlerContext<R>` |
@@ -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.1"
7
+ version: "1.4"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -37,11 +37,14 @@ export const fetchTool = tool('fetch_articles', {
37
37
 
38
38
  errors: [
39
39
  { reason: 'no_match', code: JsonRpcErrorCode.NotFound,
40
- when: 'No requested PMID returned data' },
40
+ when: 'No requested PMID returned data',
41
+ recovery: 'Try pubmed_search_articles to discover valid PMIDs first.' },
41
42
  { reason: 'queue_full', code: JsonRpcErrorCode.RateLimited,
42
- when: 'Local request queue is at capacity', retryable: true },
43
+ when: 'Local request queue is at capacity', retryable: true,
44
+ recovery: 'Wait 30 seconds and retry, or reduce batch size.' },
43
45
  { reason: 'ncbi_down', code: JsonRpcErrorCode.ServiceUnavailable,
44
- when: 'NCBI E-utilities unreachable after retries', retryable: true },
46
+ when: 'NCBI E-utilities unreachable after retries', retryable: true,
47
+ recovery: 'NCBI is degraded; retry in a few minutes.' },
45
48
  ],
46
49
 
47
50
  async handler(input, ctx) {
@@ -61,9 +64,60 @@ export const fetchTool = tool('fetch_articles', {
61
64
  |:--------|:---------|
62
65
  | Compile time | `ctx.fail('typo')` is a TS error. Auto-completes declared reasons. |
63
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. |
64
- | Lint (startup) | Each `code` validated against `JsonRpcErrorCode`. Reasons validated as snake_case + unique within contract. |
67
+ | Lint (startup) | Each `code` validated against `JsonRpcErrorCode`. Reasons validated as snake_case + unique within contract. `recovery` validated as non-empty and ≥ 5 words. |
65
68
  | Lint (conformance) | If the handler `throw new McpError(JsonRpcErrorCode.X)` outside `ctx.fail`, conformance check warns when X isn't declared. |
66
69
 
70
+ > **`recovery` is opt-in resolution, not auto-population.** The contract `recovery` is required metadata documenting the agent's next move when this failure mode fires (a forcing function for thoughtful guidance — placeholders like "Try again." get flagged by the linter). It does **not** automatically appear in runtime `data.recovery.hint` — the framework never injects it without an explicit signal at the throw site. Authors opt in by spreading `ctx.recoveryFor('reason')` into the `data` argument, the same way `ctx.fail('reason')` opts into resolving the contract `code`. What the author types at the throw site is what flows to the wire, with no hidden transformation; the resolver is just a typed lookup keyed by the same `reason` the author already typed.
71
+
72
+ #### `ctx.recoveryFor` — opt-in contract resolution
73
+
74
+ `ctx.recoveryFor(reason)` returns `{ recovery: { hint: <contract.recovery> } }` for a declared reason, ready to spread into `data`. Always available on `Context` (returns `{}` when no contract is attached or the reason is unknown — spread-safe with no optional chaining). On `HandlerContext<R>` it tightens to a typed signature constrained to the declared reason union.
75
+
76
+ ```ts
77
+ export const calculateTool = tool('calculate', {
78
+ // ...
79
+ errors: [
80
+ { reason: 'empty_expression', code: JsonRpcErrorCode.ValidationError,
81
+ when: 'Expression is empty or whitespace-only.',
82
+ recovery: 'Provide a non-empty mathematical expression to evaluate.' },
83
+ ],
84
+ handler(input, ctx) {
85
+ if (!input.expression.trim()) {
86
+ // Static recovery — resolve from the contract.
87
+ throw ctx.fail('empty_expression', undefined, { ...ctx.recoveryFor('empty_expression') });
88
+ }
89
+ // ...
90
+ },
91
+ });
92
+ ```
93
+
94
+ Same pattern works inside services that accept `ctx`:
95
+
96
+ ```ts
97
+ export class MathService {
98
+ parse(expr: string, ctx: Context) {
99
+ try {
100
+ return mathjs.parse(expr);
101
+ } catch (err) {
102
+ throw validationError(`Parse failed: ${err.message}`, {
103
+ reason: 'parse_failed',
104
+ ...ctx.recoveryFor('parse_failed'), // {} if calling tool has no matching reason
105
+ });
106
+ }
107
+ }
108
+ }
109
+ ```
110
+
111
+ The contract is the single source of truth — write the recovery once, lint validates ≥5 words, the resolver carries it to every throw site that opts in. For runtime-context recovery (interpolating input values, attempted IDs, queue state), override at the throw site:
112
+
113
+ ```ts
114
+ throw ctx.fail('no_match', `No item ${id}`, {
115
+ recovery: { hint: `No item ${id}; try IDs 1-100 instead.` },
116
+ });
117
+ ```
118
+
119
+ `ctx.recoveryFor` is the first member of a planned **family of opt-in resolution helpers**. Future contract-bound fields (`troubleshootingFor`, `userMessageFor`, …) follow the same shape: single-purpose, spreadable wire-shape, `{}` fallback when not applicable.
120
+
67
121
  **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.
68
122
 
69
123
  > **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.
@@ -81,13 +135,28 @@ throw serviceUnavailable('Upstream timeout', { reason: 'evaluation_time
81
135
  ```ts
82
136
  // my-tool.tool.ts
83
137
  errors: [
84
- { reason: 'empty_expression', code: JsonRpcErrorCode.ValidationError, when: 'Input is empty.' },
85
- { reason: 'evaluation_timeout', code: JsonRpcErrorCode.ServiceUnavailable, when: 'Upstream exceeded the configured timeout.' },
138
+ { reason: 'empty_expression', code: JsonRpcErrorCode.ValidationError,
139
+ when: 'Input is empty.',
140
+ recovery: 'Provide a non-empty expression to evaluate.' },
141
+ { reason: 'evaluation_timeout', code: JsonRpcErrorCode.ServiceUnavailable,
142
+ when: 'Upstream exceeded the configured timeout.',
143
+ recovery: 'Simplify the expression or retry the request after a brief delay.' },
86
144
  ]
87
145
  ```
88
146
 
89
147
  The handler doesn't catch and re-throw — letting service errors bubble unchanged keeps "logic throws, framework catches" intact. 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.
90
148
 
149
+ To carry the contract `recovery` from a service throw, accept `ctx` and spread the resolver:
150
+
151
+ ```ts
152
+ throw validationError(message, {
153
+ reason: 'parse_failed',
154
+ ...ctx.recoveryFor('parse_failed'), // {} when calling tool has no matching reason
155
+ });
156
+ ```
157
+
158
+ `ctx.recoveryFor` is always present on `Context` (no-op when no contract), so services don't need to know which tool called them — the spread is safe either way.
159
+
91
160
  ---
92
161
 
93
162
  ## Error Factories (fallback)
@@ -416,6 +485,9 @@ The linter validates the structure of `errors[]` and (when present) cross-checks
416
485
  | `error-contract-reason-format` | warning | `reason` not snake_case |
417
486
  | `error-contract-reason-unique` | error | Duplicate `reason` within one contract |
418
487
  | `error-contract-when-required` | error | `when` missing or empty |
488
+ | `error-contract-recovery-required` | error | `recovery` missing or not a string |
489
+ | `error-contract-recovery-empty` | error | `recovery` is empty/whitespace-only |
490
+ | `error-contract-recovery-min-words` | warning | `recovery` has fewer than 5 words — placeholders like "Try again." or "Check input." get flagged in favor of specific guidance |
419
491
  | `error-contract-retryable-type` | warning | `retryable` is present but not a boolean |
420
492
 
421
493
  ### Conformance rules
@@ -4,7 +4,7 @@ description: >
4
4
  Design the tool surface, resources, and service layer for a new MCP server. Use when starting a new server, planning a major feature expansion, or when the user describes a domain/API they want to expose via MCP. Produces a design doc at docs/design.md that drives implementation.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.7"
7
+ version: "2.8"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -346,10 +346,17 @@ throw new Error('Not found');
346
346
  // Good — names both resolution options
347
347
  "No session working directory set. Please specify a 'path' or use 'git_set_working_dir' first."
348
348
 
349
- // Good — structured hint in error data
349
+ // Good — structured hint in error data using the canonical `data.recovery.hint` shape.
350
+ // The framework auto-mirrors `data.recovery.hint` into the content[] text as
351
+ // `Recovery: <hint>` so format()-only clients (Claude Desktop) see the same
352
+ // guidance structuredContent clients (Claude Code) read from `error.data.recovery.hint`.
350
353
  throw forbidden(
351
354
  "Cannot perform 'reset --hard' on protected branch 'main' without explicit confirmation.",
352
- { branch: 'main', operation: 'reset --hard', hint: 'Set the confirmed parameter to true to proceed.' },
355
+ {
356
+ branch: 'main',
357
+ operation: 'reset --hard',
358
+ recovery: { hint: 'Set the confirmed parameter to true to proceed.' },
359
+ },
353
360
  );
354
361
 
355
362
  // Good — upstream error with actionable context
@@ -168,11 +168,13 @@ Handlers receive a unified `ctx` object. Key properties:
168
168
 
169
169
  Handlers throw — the framework catches, classifies, and formats.
170
170
 
171
- **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, retryable? }]` on `tool()` / `resource()` to receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring.
171
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. The `recovery` field is required descriptive metadata for the agent's next move (≥ 5 words, lint-validated); for the wire payload's `data.recovery.hint` (which the framework mirrors into `content[]` text), pass it explicitly at the throw site when dynamic context matters: `ctx.fail('reason', msg, { recovery: { hint: '...' } })`. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring.
172
172
 
173
173
  ```ts
174
174
  errors: [
175
- { reason: 'no_match', code: JsonRpcErrorCode.NotFound, when: 'No item matched the query' },
175
+ { reason: 'no_match', code: JsonRpcErrorCode.NotFound,
176
+ when: 'No item matched the query',
177
+ recovery: 'Broaden the query or check the spelling and try again.' },
176
178
  ],
177
179
  async handler(input, ctx) {
178
180
  const item = await db.find(input.id);
@@ -168,11 +168,13 @@ Handlers receive a unified `ctx` object. Key properties:
168
168
 
169
169
  Handlers throw — the framework catches, classifies, and formats.
170
170
 
171
- **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, retryable? }]` on `tool()` / `resource()` to receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring.
171
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. The `recovery` field is required descriptive metadata for the agent's next move (≥ 5 words, lint-validated); for the wire payload's `data.recovery.hint` (which the framework mirrors into `content[]` text), pass it explicitly at the throw site when dynamic context matters: `ctx.fail('reason', msg, { recovery: { hint: '...' } })`. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring.
172
172
 
173
173
  ```ts
174
174
  errors: [
175
- { reason: 'no_match', code: JsonRpcErrorCode.NotFound, when: 'No item matched the query' },
175
+ { reason: 'no_match', code: JsonRpcErrorCode.NotFound,
176
+ when: 'No item matched the query',
177
+ recovery: 'Broaden the query or check the spelling and try again.' },
176
178
  ],
177
179
  async handler(input, ctx) {
178
180
  const item = await db.find(input.id);
@@ -28,6 +28,7 @@ export const echoTool = tool('template_echo_message', {
28
28
  reason: 'empty_message',
29
29
  code: JsonRpcErrorCode.InvalidParams,
30
30
  when: 'Message contained only whitespace.',
31
+ recovery: 'Provide a message with at least one non-whitespace character.',
31
32
  },
32
33
  ],
33
34