@cyanheads/mcp-ts-core 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +14 -4
- package/README.md +1 -1
- package/changelog/0.8.x/0.8.3.md +43 -0
- package/changelog/0.8.x/0.8.4.md +60 -0
- package/dist/linter/rules/error-contract-rules.d.ts +3 -1
- package/dist/linter/rules/error-contract-rules.d.ts.map +1 -1
- package/dist/linter/rules/error-contract-rules.js +40 -2
- package/dist/linter/rules/error-contract-rules.js.map +1 -1
- package/dist/logs/combined.log +4 -4
- package/dist/logs/error.log +4 -4
- package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
- package/dist/mcp-server/resources/resource-registration.js +2 -3
- package/dist/mcp-server/resources/resource-registration.js.map +1 -1
- package/dist/mcp-server/resources/utils/resourceDefinition.d.ts +3 -3
- package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
- package/dist/mcp-server/tools/tool-registration.js +8 -12
- package/dist/mcp-server/tools/tool-registration.js.map +1 -1
- package/dist/mcp-server/tools/utils/toolDefinition.d.ts +7 -5
- package/dist/mcp-server/tools/utils/toolDefinition.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolDefinition.js +5 -2
- package/dist/mcp-server/tools/utils/toolDefinition.js.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +28 -0
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js +64 -23
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
- package/dist/types-global/errors.d.ts +27 -25
- package/dist/types-global/errors.d.ts.map +1 -1
- package/dist/types-global/errors.js +0 -19
- package/dist/types-global/errors.js.map +1 -1
- package/package.json +11 -11
- package/skills/add-resource/SKILL.md +1 -1
- package/skills/add-tool/SKILL.md +115 -26
- package/skills/api-errors/SKILL.md +40 -14
- package/skills/api-linter/SKILL.md +1 -1
- package/skills/design-mcp-server/SKILL.md +11 -4
- package/skills/field-test/SKILL.md +15 -6
- package/templates/AGENTS.md +6 -3
- package/templates/CLAUDE.md +6 -3
- package/templates/src/mcp-server/tools/definitions/echo.tool.ts +19 -1
package/skills/add-tool/SKILL.md
CHANGED
|
@@ -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: "
|
|
7
|
+
version: "2.2"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -44,6 +44,7 @@ For shape selection (Workflow or Instruction variants — standard single-action
|
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
46
|
import { tool, z } from '@cyanheads/mcp-ts-core';
|
|
47
|
+
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
|
|
47
48
|
|
|
48
49
|
export const {{TOOL_EXPORT}} = tool('{{tool_name}}', {
|
|
49
50
|
title: '{{TOOL_TITLE}}',
|
|
@@ -58,17 +59,36 @@ export const {{TOOL_EXPORT}} = tool('{{tool_name}}', {
|
|
|
58
59
|
// All fields need .describe(). Only JSON-Schema-serializable Zod types allowed.
|
|
59
60
|
}),
|
|
60
61
|
// auth: ['tool:{{tool_name}}:read'],
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
62
|
+
|
|
63
|
+
// Each entry declares a domain-specific failure mode and types
|
|
64
|
+
// `ctx.fail(reason, …)` against the declared union. Baseline codes
|
|
65
|
+
// (InternalError, ServiceUnavailable, Timeout, ValidationError,
|
|
66
|
+
// SerializationError) bubble freely — only declare domain-specific reasons.
|
|
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. Contract-level `recovery` is
|
|
72
|
+
// descriptive metadata; for the wire payload's `data.recovery.hint` (which
|
|
73
|
+
// the framework mirrors into content[] text), pass it explicitly at the
|
|
74
|
+
// throw site when dynamic context matters.
|
|
75
|
+
errors: [
|
|
76
|
+
{ reason: 'no_match', code: JsonRpcErrorCode.NotFound,
|
|
77
|
+
when: 'No items matched the query.',
|
|
78
|
+
recovery: 'Broaden the query or check the spelling and try again.' },
|
|
79
|
+
{ reason: 'queue_full', code: JsonRpcErrorCode.RateLimited,
|
|
80
|
+
when: 'Local queue at capacity.', retryable: true,
|
|
81
|
+
recovery: 'Wait a few seconds before retrying or reduce batch size.' },
|
|
82
|
+
],
|
|
65
83
|
|
|
66
84
|
async handler(input, ctx) {
|
|
67
85
|
ctx.log.info('Processing', { /* relevant input fields */ });
|
|
68
86
|
// Pure logic — throw on failure, no try/catch.
|
|
69
87
|
// With an `errors[]` contract: `throw ctx.fail('reason_id', message?, data?)`.
|
|
70
88
|
// Without: throw via factories (`notFound`, `validationError`, …) or plain `Error`.
|
|
71
|
-
|
|
89
|
+
const items = await search(input);
|
|
90
|
+
if (items.length === 0) throw ctx.fail('no_match', `No items matched "${input.query}"`);
|
|
91
|
+
return { items };
|
|
72
92
|
},
|
|
73
93
|
|
|
74
94
|
// format() populates MCP content[] — the markdown twin of structuredContent.
|
|
@@ -186,18 +206,38 @@ Single-item tools don't need this — they either succeed or throw. The partial
|
|
|
186
206
|
|
|
187
207
|
### Empty results need context
|
|
188
208
|
|
|
189
|
-
An empty array with no explanation is a dead end. Echo back the criteria that produced zero results and, where possible, suggest how to broaden the search.
|
|
209
|
+
An empty array with no explanation is a dead end. Echo back the criteria that produced zero results and, where possible, suggest how to broaden the search. The recovery hint needs three pieces working together — schema entry, handler return, and `format()` rendering — or the `format-parity` lint will flag the missing field.
|
|
190
210
|
|
|
191
211
|
```typescript
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
212
|
+
// 1. Output schema — declare the recovery field so the linter sees it
|
|
213
|
+
output: z.object({
|
|
214
|
+
items: z.array(ItemSchema).describe('Matching items.'),
|
|
215
|
+
totalCount: z.number().describe('Total matches before pagination.'),
|
|
216
|
+
message: z.string().optional()
|
|
217
|
+
.describe('Recovery hint when results are empty — echoes filters and suggests how to broaden. Absent on successful result pages.'),
|
|
218
|
+
}),
|
|
219
|
+
|
|
220
|
+
// 2. Handler — populate `message` when the result is empty
|
|
221
|
+
async handler(input, ctx) {
|
|
222
|
+
const results = await search(input);
|
|
223
|
+
if (results.length === 0) {
|
|
224
|
+
return {
|
|
225
|
+
items: [],
|
|
226
|
+
totalCount: 0,
|
|
227
|
+
message: `No items matched status="${input.status}" in project "${input.project}". `
|
|
228
|
+
+ `Try a broader status filter or verify the project name.`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
return { items: results, totalCount: results.length };
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
// 3. format() — render the recovery hint so content[]-only clients see it too
|
|
235
|
+
format: (result) => {
|
|
236
|
+
const lines = [`**Total:** ${result.totalCount}`];
|
|
237
|
+
if (result.message) lines.push(`\n> ${result.message}`);
|
|
238
|
+
for (const item of result.items) lines.push(`- ${item.name}`);
|
|
239
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
240
|
+
},
|
|
201
241
|
```
|
|
202
242
|
|
|
203
243
|
### Sparse upstream data must stay honest
|
|
@@ -248,9 +288,11 @@ export const fetchArticles = tool('fetch_articles', {
|
|
|
248
288
|
description: 'Fetch articles by PMID.',
|
|
249
289
|
errors: [
|
|
250
290
|
{ reason: 'no_pmid_match', code: JsonRpcErrorCode.NotFound,
|
|
251
|
-
when: 'None of the requested PMIDs returned data.'
|
|
291
|
+
when: 'None of the requested PMIDs returned data.',
|
|
292
|
+
recovery: 'Try pubmed_search_articles to discover valid PMIDs first.' },
|
|
252
293
|
{ reason: 'queue_full', code: JsonRpcErrorCode.RateLimited,
|
|
253
|
-
when: 'Local request queue at capacity.', retryable: true
|
|
294
|
+
when: 'Local request queue at capacity.', retryable: true,
|
|
295
|
+
recovery: 'Wait 30 seconds and retry, or reduce batch size.' },
|
|
254
296
|
],
|
|
255
297
|
input: z.object({ pmids: z.array(z.string()).describe('PMIDs to fetch') }),
|
|
256
298
|
output: z.object({ articles: z.array(ArticleSchema).describe('Resolved articles') }),
|
|
@@ -265,13 +307,52 @@ export const fetchArticles = tool('fetch_articles', {
|
|
|
265
307
|
});
|
|
266
308
|
```
|
|
267
309
|
|
|
268
|
-
**Baseline codes** (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring.
|
|
310
|
+
**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.
|
|
269
311
|
|
|
270
312
|
`ctx.fail` accepts an optional 4th `options` argument for ES2022 cause chaining: `throw ctx.fail('upstream_error', 'Upstream returned 500', { url }, { cause: e })`.
|
|
271
313
|
|
|
272
|
-
|
|
314
|
+
#### Service-layer throws
|
|
315
|
+
|
|
316
|
+
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.
|
|
317
|
+
|
|
318
|
+
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.
|
|
273
319
|
|
|
274
|
-
|
|
320
|
+
```typescript
|
|
321
|
+
// service — passes data.reason to match the consuming tool's contract
|
|
322
|
+
import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
|
|
323
|
+
|
|
324
|
+
export class NcbiService {
|
|
325
|
+
async fetch(pmids: string[]) {
|
|
326
|
+
const response = await fetchWithRetry(...);
|
|
327
|
+
if (!response.ok) {
|
|
328
|
+
throw serviceUnavailable(
|
|
329
|
+
`NCBI returned HTTP ${response.status}`,
|
|
330
|
+
{ reason: 'ncbi_unreachable', status: response.status }, // ← matches contract entry
|
|
331
|
+
{ cause: undefined },
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return response.json();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// tool — declares the matching contract entry, calls the service, doesn't catch
|
|
339
|
+
export const fetchArticles = tool('fetch_articles', {
|
|
340
|
+
errors: [
|
|
341
|
+
{ reason: 'ncbi_unreachable', code: JsonRpcErrorCode.ServiceUnavailable,
|
|
342
|
+
when: 'NCBI E-utilities is unreachable.', retryable: true,
|
|
343
|
+
recovery: 'NCBI is degraded; retry in a few minutes.' },
|
|
344
|
+
],
|
|
345
|
+
async handler(input, ctx) {
|
|
346
|
+
return { articles: await ncbi.fetch(input.pmids) }; // throws bubble unchanged
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
See `add-service` for the full pattern.
|
|
352
|
+
|
|
353
|
+
#### Ad-hoc factory throws (fallback)
|
|
354
|
+
|
|
355
|
+
When no contract entry fits — prototype code, one-off throws, or service-layer fallbacks — use error factories or plain `throw new Error()`. The framework auto-classifies plain `Error` from message patterns as a last resort.
|
|
275
356
|
|
|
276
357
|
```typescript
|
|
277
358
|
// Client input error — agent can fix and retry
|
|
@@ -287,15 +368,23 @@ throw notFound(
|
|
|
287
368
|
import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
|
|
288
369
|
throw serviceUnavailable(`arXiv API returned HTTP ${status}. Retry in a few seconds.`);
|
|
289
370
|
|
|
290
|
-
//
|
|
371
|
+
// Recovery hint via the canonical `data.recovery.hint` shape — the framework
|
|
372
|
+
// auto-mirrors it into the content[] text as `Recovery: <hint>`, so format()-only
|
|
373
|
+
// clients (Claude Desktop) see the same guidance that structuredContent clients
|
|
374
|
+
// (Claude Code) read from `error.data.recovery.hint`. Other `data` keys reach
|
|
375
|
+
// structuredContent only.
|
|
291
376
|
import { invalidParams } from '@cyanheads/mcp-ts-core/errors';
|
|
292
377
|
throw invalidParams(
|
|
293
|
-
`Date range exceeds 90-day API limit
|
|
294
|
-
{
|
|
378
|
+
`Date range exceeds 90-day API limit.`,
|
|
379
|
+
{
|
|
380
|
+
maxDays: 90,
|
|
381
|
+
requestedDays: daysBetween,
|
|
382
|
+
recovery: { hint: 'Narrow the range or split into multiple queries.' },
|
|
383
|
+
},
|
|
295
384
|
);
|
|
296
385
|
```
|
|
297
386
|
|
|
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,
|
|
387
|
+
**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).
|
|
299
388
|
|
|
300
389
|
### Include operational metadata
|
|
301
390
|
|
|
@@ -365,7 +454,7 @@ Large payloads burn the agent's context window. Default to curated summaries; of
|
|
|
365
454
|
- [ ] `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
|
|
366
455
|
- [ ] If wrapping external API: output schema and `format()` preserve uncertainty from sparse upstream payloads instead of inventing concrete values
|
|
367
456
|
- [ ] `auth` scopes declared if the tool needs authorization
|
|
368
|
-
- [ ] `errors: [...]` contract declared for
|
|
457
|
+
- [ ] `errors: [...]` contract declared for the tool's domain-specific failure modes — or block deleted if no domain failures apply (baseline codes bubble freely)
|
|
369
458
|
- [ ] `task: true` added if the tool is long-running
|
|
370
459
|
- [ ] Registered in the project's existing `createApp()` tool list (directly or via barrel)
|
|
371
460
|
- [ ] `bun run devcheck` passes
|
|
@@ -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.
|
|
7
|
+
version: "1.2"
|
|
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,11 +64,12 @@ 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
|
-
| `
|
|
65
|
-
| 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. |
|
|
66
68
|
| Lint (conformance) | If the handler `throw new McpError(JsonRpcErrorCode.X)` outside `ctx.fail`, conformance check warns when X isn't declared. |
|
|
67
69
|
|
|
68
|
-
|
|
70
|
+
> **`recovery` is descriptive, not auto-injected.** 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 is **not** auto-populated into runtime `data.recovery.hint`. The wire payload's recovery hint — which the framework mirrors into `content[]` text per the [error-path parity](#error-path-parity) invariant — is populated separately at the throw site, where dynamic context (input values, attempted IDs, queue state) is available: `ctx.fail('reason', msg, { recovery: { hint: '...' } })`. The two fields can carry the same string when no dynamic context is needed; they're decoupled by design — what the author writes at the throw site is what flows to the wire, with no hidden transformation.
|
|
71
|
+
|
|
72
|
+
**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.
|
|
69
73
|
|
|
70
74
|
> **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
75
|
|
|
@@ -82,12 +86,16 @@ throw serviceUnavailable('Upstream timeout', { reason: 'evaluation_time
|
|
|
82
86
|
```ts
|
|
83
87
|
// my-tool.tool.ts
|
|
84
88
|
errors: [
|
|
85
|
-
{ reason: 'empty_expression', code: JsonRpcErrorCode.ValidationError,
|
|
86
|
-
|
|
89
|
+
{ reason: 'empty_expression', code: JsonRpcErrorCode.ValidationError,
|
|
90
|
+
when: 'Input is empty.',
|
|
91
|
+
recovery: 'Provide a non-empty expression to evaluate.' },
|
|
92
|
+
{ reason: 'evaluation_timeout', code: JsonRpcErrorCode.ServiceUnavailable,
|
|
93
|
+
when: 'Upstream exceeded the configured timeout.',
|
|
94
|
+
recovery: 'Simplify the expression or retry the request after a brief delay.' },
|
|
87
95
|
]
|
|
88
96
|
```
|
|
89
97
|
|
|
90
|
-
The handler doesn't catch and re-throw — letting service errors bubble unchanged keeps "logic throws, framework catches" intact. The
|
|
98
|
+
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.
|
|
91
99
|
|
|
92
100
|
---
|
|
93
101
|
|
|
@@ -266,9 +274,24 @@ Checked before common patterns. Cover: AWS exception names, HTTP status codes, D
|
|
|
266
274
|
| Layer | Pattern |
|
|
267
275
|
|:------|:--------|
|
|
268
276
|
| Tool/resource handlers | Throw `McpError` — no try/catch |
|
|
269
|
-
| Handler factory | Catches all errors, normalizes to `McpError`, sets `isError: true
|
|
277
|
+
| Handler factory (tools) | Catches all errors, normalizes to `McpError`, sets `isError: true`, mirrors error across both client surfaces (see [Error-path parity](#error-path-parity)) |
|
|
278
|
+
| Handler factory (resources) | Catches and re-throws to the SDK, which routes through the JSON-RPC error envelope |
|
|
270
279
|
| Services/setup code | `ErrorHandler.tryCatch` for graceful recovery |
|
|
271
280
|
|
|
281
|
+
### Error-path parity
|
|
282
|
+
|
|
283
|
+
MCP clients differ in which `CallToolResult` surface they forward to the agent. Tool errors mirror the success-path `format-parity` invariant — both surfaces carry the same payload:
|
|
284
|
+
|
|
285
|
+
| Surface | Content | Read by |
|
|
286
|
+
|:--------|:--------|:--------|
|
|
287
|
+
| `content[]` | Text rendering: `Error: <message>` (plus `Recovery: <hint>` when `data.recovery.hint` is present) | Claude Desktop and other format()-only clients |
|
|
288
|
+
| `structuredContent.error` | JSON `{ code, message, data? }` carrying the error code, message, and any structured data from the thrown `McpError` or `ZodError` | Claude Code and other structuredContent-only clients |
|
|
289
|
+
|
|
290
|
+
Important properties:
|
|
291
|
+
- **`_meta.error` is NOT emitted.** Error code/data live on `structuredContent.error` instead. Don't read `_meta.error` in clients or tests — it doesn't exist.
|
|
292
|
+
- **`data` propagation is restricted** to explicitly-thrown `McpError.data` and `ZodError.issues`. Auto-classified plain errors (`TypeError`, network errors, etc.) emit `code` + `message` only — no `data` — so internal classification context never leaks to clients.
|
|
293
|
+
- **Recovery hint mirroring is automatic.** When the thrown `McpError` carries `data.recovery.hint`, the handler factory appends it to the `content[]` text so the markdown surface matches the JSON surface. Authors don't need to format the hint manually.
|
|
294
|
+
|
|
272
295
|
**Handler — throw freely, no try/catch:**
|
|
273
296
|
|
|
274
297
|
```ts
|
|
@@ -349,7 +372,7 @@ if (!response.ok) {
|
|
|
349
372
|
|
|
350
373
|
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
374
|
|
|
352
|
-
> **Body reaches the client.** `error.data` is forwarded to the MCP client as `
|
|
375
|
+
> **Body reaches the client.** `error.data` is forwarded to the MCP client as `structuredContent.error.data` (tool errors) or JSON-RPC `error.data` (resource errors). Upstream 401/403/422 responses sometimes echo token claims, internal user IDs, or schema validation hints — that text becomes client-visible. For sensitive endpoints, pass `captureBody: false` (or `bodyLimit: 0`) so the body stays out of `data`. Defaults remain `captureBody: true` because most upstreams return useful diagnostic text and silent dropping helps no one debug.
|
|
353
376
|
|
|
354
377
|
Full status table:
|
|
355
378
|
|
|
@@ -402,13 +425,16 @@ The linter validates the structure of `errors[]` and (when present) cross-checks
|
|
|
402
425
|
| `error-contract-reason-format` | warning | `reason` not snake_case |
|
|
403
426
|
| `error-contract-reason-unique` | error | Duplicate `reason` within one contract |
|
|
404
427
|
| `error-contract-when-required` | error | `when` missing or empty |
|
|
428
|
+
| `error-contract-recovery-required` | error | `recovery` missing or not a string |
|
|
429
|
+
| `error-contract-recovery-empty` | error | `recovery` is empty/whitespace-only |
|
|
430
|
+
| `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 |
|
|
405
431
|
| `error-contract-retryable-type` | warning | `retryable` is present but not a boolean |
|
|
406
432
|
|
|
407
433
|
### Conformance rules
|
|
408
434
|
|
|
409
435
|
| Rule | Severity | Catches |
|
|
410
436
|
|:-----|:---------|:--------|
|
|
411
|
-
| `error-contract-conformance` | warning | Handler throws a non-baseline code that isn't in the contract. Suggests adding it to `errors[]` so
|
|
437
|
+
| `error-contract-conformance` | warning | Handler throws a non-baseline code that isn't in the contract. Suggests adding it to `errors[]` so the contract is the canonical source of truth for declared failure modes. |
|
|
412
438
|
| `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
439
|
|
|
414
440
|
### Baseline codes (auto-allowed)
|
|
@@ -421,7 +447,7 @@ These codes bubble up from anywhere — services, framework utilities, the auto-
|
|
|
421
447
|
- `ValidationError` — schema violations, malformed input
|
|
422
448
|
- `SerializationError` — JSON/XML parse failures
|
|
423
449
|
|
|
424
|
-
If you *want* to
|
|
450
|
+
If you *want* to declare one of these as a domain-specific failure (e.g., a tool that intentionally times out under defined conditions), put it in `errors[]` anyway — the contract still binds `ctx.fail(reason)` and the conformance lint will catch undeclared throws. The lint just doesn't *require* you to enumerate baselines.
|
|
425
451
|
|
|
426
452
|
### When to declare vs. let it bubble
|
|
427
453
|
|
|
@@ -502,7 +502,7 @@ throw serviceUnavailable('Upstream failed', { upstreamError: e }, { cause: e });
|
|
|
502
502
|
|
|
503
503
|
Validate the optional `errors[]` declarative contract on tool/resource definitions. Structural rules check the shape of contract entries; conformance rules cross-check the handler body against the declared codes.
|
|
504
504
|
|
|
505
|
-
When a contract is declared,
|
|
505
|
+
When a contract is declared, the handler receives a typed `ctx.fail(reason, …)` keyed by the declared reason union. See `skills/api-errors/SKILL.md` for runtime semantics.
|
|
506
506
|
|
|
507
507
|
### error-contract-type
|
|
508
508
|
|
|
@@ -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
|
+
version: "2.8"
|
|
8
8
|
audience: external
|
|
9
9
|
type: workflow
|
|
10
10
|
---
|
|
@@ -323,7 +323,7 @@ The pattern: name the shortcut for what it does (`text_search`, `name_search`),
|
|
|
323
323
|
|
|
324
324
|
Errors are part of the tool's interface — design them during the design phase, not as an afterthought. Three aspects: **the contract** (which failures are public), **classification** (what error code), and **messaging** (what the LLM reads).
|
|
325
325
|
|
|
326
|
-
**Declare a typed contract for domain failures.** When a tool has known failure modes the agent should plan around (`no_match`, `queue_full`, `vendor_down`), enumerate them as `errors: [{ reason, code, when, retryable? }]` on the definition. The framework
|
|
326
|
+
**Declare a typed contract for domain failures.** When a tool has known failure modes the agent should plan around (`no_match`, `queue_full`, `vendor_down`), enumerate them as `errors: [{ reason, code, when, retryable? }]` on the definition. The framework types `ctx.fail(reason, …)` against the declared reason union (typos become TS errors) and auto-populates `data.reason` on the thrown error for stable observability. The error reaches clients with parity across both surfaces — `structuredContent.error` (Claude Code) and `content[]` text (Claude Desktop). Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble from anywhere and don't need to be enumerated. See `api-errors` skill for the full pattern.
|
|
327
327
|
|
|
328
328
|
**Classify errors by origin.** Different error sources need different codes and different recovery guidance. Map the failure modes for each tool during design:
|
|
329
329
|
|
|
@@ -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
|
-
{
|
|
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
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Exercise tools, resources, and prompts against a live HTTP server via MCP JSON-RPC over curl. Starts the server, surfaces the catalog, runs real and adversarial inputs, and produces a tight report with concrete findings and numbered follow-up options. Use after adding or modifying definitions, or when the user asks to test, try out, or verify their MCP surface.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "2.
|
|
7
|
+
version: "2.2"
|
|
8
8
|
audience: external
|
|
9
9
|
type: debug
|
|
10
10
|
---
|
|
@@ -15,6 +15,15 @@ Unit tests (`add-test` skill) verify handler logic with mocked context. Field te
|
|
|
15
15
|
|
|
16
16
|
**Actively call the tools. Don't read code and guess.**
|
|
17
17
|
|
|
18
|
+
### Transport coverage
|
|
19
|
+
|
|
20
|
+
This skill drives an HTTP server because curl + JSON-RPC is the most reliable harness for shell-based agents. Most servers ship both transports (`bun run dev:http` and `dev:stdio`), so HTTP coverage is sufficient: the same handler runs on both, only the framing differs. If the server is **stdio-only** (no HTTP transport in `package.json` / no `MCP_TRANSPORT_TYPE=http` path), drive it through one of:
|
|
21
|
+
|
|
22
|
+
- **MCP Inspector** (`npx @modelcontextprotocol/inspector bun run dev:stdio`) — interactive UI for catalog browsing and tool calls; best for hands-on exploration
|
|
23
|
+
- **mcp-cli** (`uvx mcp-cli --stdio bun run dev:stdio`) — scriptable JSON-RPC client; best for batch/agentic testing
|
|
24
|
+
|
|
25
|
+
Adapt the test plan below the same way — universal battery on every definition, situational categories only when triggered, same error-contract verification — but call the tools through the inspector / mcp-cli rather than `mcp_call`. Pino startup + handler logs land on stderr in stdio mode (stdout is reserved for JSON-RPC), so tail with `2>/tmp/mcp-server.log` if you start the server yourself.
|
|
26
|
+
|
|
18
27
|
---
|
|
19
28
|
|
|
20
29
|
## Steps
|
|
@@ -190,8 +199,8 @@ Runs `initialize`, captures the session id, sends `notifications/initialized`.
|
|
|
190
199
|
|
|
191
200
|
```bash
|
|
192
201
|
. /tmp/mcp-field-test.sh
|
|
193
|
-
mcp_call tools/list | jq '.result.tools[] | {name, description, inputSchema, outputSchema
|
|
194
|
-
mcp_call resources/list | jq '.result.resources[] | {uri, name, mimeType
|
|
202
|
+
mcp_call tools/list | jq '.result.tools[] | {name, description, inputSchema, outputSchema}'
|
|
203
|
+
mcp_call resources/list | jq '.result.resources[] | {uri, name, mimeType}'
|
|
195
204
|
mcp_call prompts/list | jq '.result.prompts[] | {name, description, arguments}'
|
|
196
205
|
```
|
|
197
206
|
|
|
@@ -229,7 +238,7 @@ Treat any hit as a `ux` finding in the report. The authoring rule lives under *T
|
|
|
229
238
|
| Hits external API / live upstream | One call that exercises upstream; note rate-limit / timeout / transient-failure behavior |
|
|
230
239
|
| Chained with other tools (search → detail → act) | Run one representative chain end-to-end; does each step return the IDs/cursors the next needs? |
|
|
231
240
|
| `cursor` / `offset` / `limit` params | Pagination: second page, end-of-list |
|
|
232
|
-
| Tool declared an `errors: [...]` contract | Error contract (tool): trigger ≥1 declared failure mode. Verify `result.
|
|
241
|
+
| Tool declared an `errors: [...]` contract | Error contract (tool): trigger ≥1 declared failure mode. Verify `result.structuredContent.error.code` matches the contract entry, `result.structuredContent.error.data.reason` is the declared reason (only present when the handler threw an `McpError` — `ctx.fail` always does, plain `throw new Error(...)` does not), and `content[0].text` is actionable. Reasons declared but unreachable from any input are dead contract entries. |
|
|
233
242
|
| Resource declared an `errors: [...]` contract | Error contract (resource): trigger ≥1 declared failure mode by reading a URI that exercises it. Resources re-throw errors at the JSON-RPC level — verify `error.code` matches the contract entry and `error.data.reason` is the declared reason. (Resources don't use the `result.isError` envelope — they fail the request itself.) |
|
|
234
243
|
|
|
235
244
|
**Resources.** Happy path, not-found URI, `list` if defined, pagination if used.
|
|
@@ -253,7 +262,7 @@ When a call surprises you — slow, hangs, returns terse output, surfaces an unh
|
|
|
253
262
|
**Interpreting responses**
|
|
254
263
|
|
|
255
264
|
- Tool domain errors return `{result: {content: [...], isError: true}}` — they live in `result`, not `error`. Check `isError`, not the JSON-RPC error field.
|
|
256
|
-
- **Tool error code/reason** rides on `result.
|
|
265
|
+
- **Tool error code/reason** rides on `result.structuredContent.error.{code, message, data?.reason}` — inspect that, not just the text. `data` is only spread when the handler threw an `McpError` (or `ZodError`); plain `throw new Error(...)` won't populate `data.reason`. Use `ctx.fail`-thrown errors when the contract reason matters. The text in `result.content[0].text` mirrors the message and includes `Recovery: <hint>` when `data.recovery.hint` is present.
|
|
257
266
|
- **Resource errors** are JSON-RPC-level — they appear in the top-level `error.{code, data.reason}` field, not inside `result`. Resource handlers re-throw rather than producing an `isError` envelope.
|
|
258
267
|
- JSON-RPC `error` only appears for protocol issues (bad session, malformed envelope, unknown method).
|
|
259
268
|
- `mcp_call` already strips SSE framing. Pipe to `jq` for readability.
|
|
@@ -317,7 +326,7 @@ End with:
|
|
|
317
326
|
- [ ] Catalog surfaced and presented; descriptions audited for leaks (implementation details, meta-coaching, consumer-aware phrasing)
|
|
318
327
|
- [ ] Universal battery run on every definition
|
|
319
328
|
- [ ] Situational categories applied only when triggered
|
|
320
|
-
- [ ] **If a tool declared an `errors: [...]` contract:** ≥1 declared failure mode triggered; `result.
|
|
329
|
+
- [ ] **If a tool declared an `errors: [...]` contract:** ≥1 declared failure mode triggered; `result.structuredContent.error.code` and `data.reason` verified against the contract entry
|
|
321
330
|
- [ ] **If a resource declared an `errors: [...]` contract:** ≥1 declared failure mode triggered; top-level JSON-RPC `error.code` and `error.data.reason` verified against the contract entry
|
|
322
331
|
- [ ] External-state / auth-gated tools handled explicitly (run, skip, or confirm)
|
|
323
332
|
- [ ] Server stopped; state file removed
|
package/templates/AGENTS.md
CHANGED
|
@@ -90,6 +90,7 @@ export const searchItems = tool('search_items', {
|
|
|
90
90
|
|
|
91
91
|
```ts
|
|
92
92
|
import { resource, z } from '@cyanheads/mcp-ts-core';
|
|
93
|
+
import { notFound } from '@cyanheads/mcp-ts-core/errors';
|
|
93
94
|
|
|
94
95
|
export const itemData = resource('inventory://{itemId}', {
|
|
95
96
|
description: 'Fetch an inventory item by ID.',
|
|
@@ -97,7 +98,7 @@ export const itemData = resource('inventory://{itemId}', {
|
|
|
97
98
|
auth: ['inventory:read'],
|
|
98
99
|
async handler(params, ctx) {
|
|
99
100
|
const item = await ctx.state.get(`item:${params.itemId}`);
|
|
100
|
-
if (!item) throw
|
|
101
|
+
if (!item) throw notFound(`Item ${params.itemId} not found`, { itemId: params.itemId });
|
|
101
102
|
return item;
|
|
102
103
|
},
|
|
103
104
|
});
|
|
@@ -167,11 +168,13 @@ Handlers receive a unified `ctx` object. Key properties:
|
|
|
167
168
|
|
|
168
169
|
Handlers throw — the framework catches, classifies, and formats.
|
|
169
170
|
|
|
170
|
-
**Recommended: typed error contract.** Declare `errors: [{ reason, code, when, retryable? }]` on `tool()` / `resource()` to
|
|
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.
|
|
171
172
|
|
|
172
173
|
```ts
|
|
173
174
|
errors: [
|
|
174
|
-
{ reason: 'no_match', code: JsonRpcErrorCode.NotFound,
|
|
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.' },
|
|
175
178
|
],
|
|
176
179
|
async handler(input, ctx) {
|
|
177
180
|
const item = await db.find(input.id);
|
package/templates/CLAUDE.md
CHANGED
|
@@ -90,6 +90,7 @@ export const searchItems = tool('search_items', {
|
|
|
90
90
|
|
|
91
91
|
```ts
|
|
92
92
|
import { resource, z } from '@cyanheads/mcp-ts-core';
|
|
93
|
+
import { notFound } from '@cyanheads/mcp-ts-core/errors';
|
|
93
94
|
|
|
94
95
|
export const itemData = resource('inventory://{itemId}', {
|
|
95
96
|
description: 'Fetch an inventory item by ID.',
|
|
@@ -97,7 +98,7 @@ export const itemData = resource('inventory://{itemId}', {
|
|
|
97
98
|
auth: ['inventory:read'],
|
|
98
99
|
async handler(params, ctx) {
|
|
99
100
|
const item = await ctx.state.get(`item:${params.itemId}`);
|
|
100
|
-
if (!item) throw
|
|
101
|
+
if (!item) throw notFound(`Item ${params.itemId} not found`, { itemId: params.itemId });
|
|
101
102
|
return item;
|
|
102
103
|
},
|
|
103
104
|
});
|
|
@@ -167,11 +168,13 @@ Handlers receive a unified `ctx` object. Key properties:
|
|
|
167
168
|
|
|
168
169
|
Handlers throw — the framework catches, classifies, and formats.
|
|
169
170
|
|
|
170
|
-
**Recommended: typed error contract.** Declare `errors: [{ reason, code, when, retryable? }]` on `tool()` / `resource()` to
|
|
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.
|
|
171
172
|
|
|
172
173
|
```ts
|
|
173
174
|
errors: [
|
|
174
|
-
{ reason: 'no_match', code: JsonRpcErrorCode.NotFound,
|
|
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.' },
|
|
175
178
|
],
|
|
176
179
|
async handler(input, ctx) {
|
|
177
180
|
const item = await db.find(input.id);
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { tool, z } from '@cyanheads/mcp-ts-core';
|
|
7
|
+
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
|
|
7
8
|
|
|
8
9
|
// Tool names are snake_case, prefixed with your server name to avoid collisions across servers.
|
|
9
10
|
// e.g. for a "tasks" server: tasks_fetch_list, tasks_create_item.
|
|
@@ -17,7 +18,24 @@ export const echoTool = tool('template_echo_message', {
|
|
|
17
18
|
message: z.string().describe('The echoed message.'),
|
|
18
19
|
}),
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
// Declare each domain failure mode the agent should plan around. The framework
|
|
22
|
+
// types `ctx.fail(reason, …)` against the declared union. Baseline codes
|
|
23
|
+
// (InternalError, ServiceUnavailable, Timeout, ValidationError,
|
|
24
|
+
// SerializationError) bubble freely — only declare domain-specific reasons.
|
|
25
|
+
// Delete this block if no domain-specific failures apply to your tool.
|
|
26
|
+
errors: [
|
|
27
|
+
{
|
|
28
|
+
reason: 'empty_message',
|
|
29
|
+
code: JsonRpcErrorCode.InvalidParams,
|
|
30
|
+
when: 'Message contained only whitespace.',
|
|
31
|
+
recovery: 'Provide a message with at least one non-whitespace character.',
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
|
|
35
|
+
handler(input, ctx) {
|
|
36
|
+
if (input.message.trim().length === 0) {
|
|
37
|
+
throw ctx.fail('empty_message', 'Message must contain at least one non-whitespace character.');
|
|
38
|
+
}
|
|
21
39
|
return { message: input.message };
|
|
22
40
|
},
|
|
23
41
|
|