@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.
- package/CLAUDE.md +22 -7
- package/README.md +2 -2
- package/changelog/0.8.x/0.8.0.md +33 -0
- package/changelog/0.8.x/0.8.1.md +17 -0
- package/changelog/template.md +13 -0
- package/dist/core/context.d.ts +67 -0
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +46 -1
- package/dist/core/context.js.map +1 -1
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -0
- package/dist/core/index.js.map +1 -1
- package/dist/linter/rules/error-contract-rules.d.ts +45 -0
- package/dist/linter/rules/error-contract-rules.d.ts.map +1 -0
- package/dist/linter/rules/error-contract-rules.js +321 -0
- package/dist/linter/rules/error-contract-rules.js.map +1 -0
- package/dist/linter/rules/handler-body-rules.d.ts +18 -0
- package/dist/linter/rules/handler-body-rules.d.ts.map +1 -0
- package/dist/linter/rules/handler-body-rules.js +134 -0
- package/dist/linter/rules/handler-body-rules.js.map +1 -0
- package/dist/linter/rules/index.d.ts +2 -0
- package/dist/linter/rules/index.d.ts.map +1 -1
- package/dist/linter/rules/index.js +2 -0
- package/dist/linter/rules/index.js.map +1 -1
- package/dist/linter/rules/resource-rules.d.ts.map +1 -1
- package/dist/linter/rules/resource-rules.js +9 -0
- package/dist/linter/rules/resource-rules.js.map +1 -1
- package/dist/linter/rules/source-text.d.ts +19 -0
- package/dist/linter/rules/source-text.d.ts.map +1 -0
- package/dist/linter/rules/source-text.js +96 -0
- package/dist/linter/rules/source-text.js.map +1 -0
- package/dist/linter/rules/tool-rules.d.ts.map +1 -1
- package/dist/linter/rules/tool-rules.js +9 -0
- package/dist/linter/rules/tool-rules.js.map +1 -1
- package/dist/logs/combined.log +4 -4
- package/dist/logs/error.log +4 -4
- package/dist/mcp-server/apps/appBuilders.d.ts +9 -4
- package/dist/mcp-server/apps/appBuilders.d.ts.map +1 -1
- package/dist/mcp-server/apps/appBuilders.js +4 -0
- package/dist/mcp-server/apps/appBuilders.js.map +1 -1
- package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
- package/dist/mcp-server/resources/resource-registration.js +3 -2
- package/dist/mcp-server/resources/resource-registration.js.map +1 -1
- package/dist/mcp-server/resources/utils/resourceDefinition.d.ts +13 -5
- package/dist/mcp-server/resources/utils/resourceDefinition.d.ts.map +1 -1
- package/dist/mcp-server/resources/utils/resourceDefinition.js.map +1 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +5 -4
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
- package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
- package/dist/mcp-server/tools/tool-registration.js +13 -7
- package/dist/mcp-server/tools/tool-registration.js.map +1 -1
- package/dist/mcp-server/tools/utils/toolDefinition.d.ts +64 -16
- package/dist/mcp-server/tools/utils/toolDefinition.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolDefinition.js +25 -11
- package/dist/mcp-server/tools/utils/toolDefinition.js.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js +6 -4
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
- package/dist/testing/index.d.ts +8 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +5 -1
- package/dist/testing/index.js.map +1 -1
- package/dist/types-global/errors.d.ts +82 -0
- package/dist/types-global/errors.d.ts.map +1 -1
- package/dist/types-global/errors.js +25 -0
- package/dist/types-global/errors.js.map +1 -1
- package/dist/utils/formatting/index.d.ts +1 -0
- package/dist/utils/formatting/index.d.ts.map +1 -1
- package/dist/utils/formatting/index.js +1 -0
- package/dist/utils/formatting/index.js.map +1 -1
- package/dist/utils/formatting/partialResult.d.ts +145 -0
- package/dist/utils/formatting/partialResult.d.ts.map +1 -0
- package/dist/utils/formatting/partialResult.js +145 -0
- package/dist/utils/formatting/partialResult.js.map +1 -0
- package/dist/utils/index.d.ts +2 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +2 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/network/httpError.d.ts +112 -0
- package/dist/utils/network/httpError.d.ts.map +1 -0
- package/dist/utils/network/httpError.js +153 -0
- package/dist/utils/network/httpError.js.map +1 -0
- package/dist/utils/network/retry.d.ts.map +1 -1
- package/dist/utils/network/retry.js +0 -1
- package/dist/utils/network/retry.js.map +1 -1
- package/package.json +5 -4
- package/scripts/split-changelog.ts +133 -0
- package/skills/add-app-tool/SKILL.md +12 -0
- package/skills/add-resource/SKILL.md +40 -0
- package/skills/add-service/SKILL.md +54 -1
- package/skills/add-test/SKILL.md +39 -0
- package/skills/add-tool/SKILL.md +42 -5
- package/skills/api-context/SKILL.md +75 -1
- package/skills/api-errors/SKILL.md +183 -5
- package/skills/api-linter/SKILL.md +223 -3
- package/skills/api-testing/SKILL.md +79 -4
- package/skills/api-utils/SKILL.md +4 -2
- package/skills/design-mcp-server/SKILL.md +13 -10
- package/skills/field-test/SKILL.md +81 -15
- package/skills/maintenance/SKILL.md +5 -2
- package/skills/report-issue-framework/SKILL.md +2 -2
- package/skills/security-pass/SKILL.md +6 -5
- package/templates/AGENTS.md +23 -8
- package/templates/CLAUDE.md +23 -8
- package/templates/changelog/template.md +18 -5
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: "1.
|
|
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
|
-
|
|
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
|
-
|
|
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?:
|
|
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.
|
|
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
|
|
25
|
+
## Type-Driven Error Contract (recommended)
|
|
26
26
|
|
|
27
|
-
|
|
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 (
|
|
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 |
|