@cyanheads/mcp-ts-core 0.7.6 → 0.8.0
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 +31 -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 +47 -0
- package/skills/add-test/SKILL.md +39 -0
- package/skills/add-tool/SKILL.md +39 -4
- package/skills/api-context/SKILL.md +75 -1
- package/skills/api-errors/SKILL.md +162 -4
- 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 +8 -2
- package/skills/maintenance/SKILL.md +2 -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
|
@@ -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 |
|
|
@@ -22,9 +22,58 @@ 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
|
+
---
|
|
73
|
+
|
|
74
|
+
## Error Factories (fallback)
|
|
75
|
+
|
|
76
|
+
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
77
|
|
|
29
78
|
```ts
|
|
30
79
|
throw notFound('Item not found', { itemId: '123' });
|
|
@@ -50,6 +99,9 @@ throw serviceUnavailable('API call failed', { url }, { cause: error });
|
|
|
50
99
|
| `timeout(msg, data?, options?)` | Timeout (-32004) |
|
|
51
100
|
| `serviceUnavailable(msg, data?, options?)` | ServiceUnavailable (-32000) |
|
|
52
101
|
| `configurationError(msg, data?, options?)` | ConfigurationError (-32008) |
|
|
102
|
+
| `internalError(msg, data?, options?)` | InternalError (-32603) |
|
|
103
|
+
| `serializationError(msg, data?, options?)` | SerializationError (-32070) — JSON/XML/parser failures |
|
|
104
|
+
| `databaseError(msg, data?, options?)` | DatabaseError (-32010) |
|
|
53
105
|
|
|
54
106
|
`options` is `{ cause?: unknown }` — the standard ES2022 `ErrorOptions` type.
|
|
55
107
|
|
|
@@ -57,7 +109,7 @@ throw serviceUnavailable('API call failed', { url }, { cause: error });
|
|
|
57
109
|
|
|
58
110
|
## McpError Constructor
|
|
59
111
|
|
|
60
|
-
For codes not covered by factories (
|
|
112
|
+
For codes not covered by factories (rare — `MethodNotFound`, `ParseError`, `InitializationFailed`, `UnknownError`):
|
|
61
113
|
|
|
62
114
|
```ts
|
|
63
115
|
throw new McpError(code, message?, data?, options?)
|
|
@@ -134,13 +186,15 @@ The framework applies these steps in order — first match wins:
|
|
|
134
186
|
| Constructor | Mapped Code |
|
|
135
187
|
|:------------|:------------|
|
|
136
188
|
| `SyntaxError` | `ValidationError` |
|
|
137
|
-
| `TypeError` | `ValidationError` |
|
|
138
189
|
| `RangeError` | `ValidationError` |
|
|
139
190
|
| `URIError` | `ValidationError` |
|
|
191
|
+
| `ZodError` | `ValidationError` |
|
|
140
192
|
| `ReferenceError` | `InternalError` |
|
|
141
193
|
| `EvalError` | `InternalError` |
|
|
142
194
|
| `AggregateError` | `InternalError` |
|
|
143
195
|
|
|
196
|
+
`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.
|
|
197
|
+
|
|
144
198
|
### Common Message Patterns
|
|
145
199
|
|
|
146
200
|
Patterns are tested against both the error `message` and `name`, case-insensitively. First match wins.
|
|
@@ -254,3 +308,107 @@ const parsed = await ErrorHandler.tryCatch(
|
|
|
254
308
|
| `critical` | `boolean` | No | Marks the error as critical in logs (default `false`) |
|
|
255
309
|
| `includeStack` | `boolean` | No | Include stack trace in log output (default `true`) |
|
|
256
310
|
| `errorMapper` | `(error: unknown) => Error` | No | Custom transform applied instead of default `McpError` wrapping |
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
## HTTP Response → McpError
|
|
315
|
+
|
|
316
|
+
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:
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
import { httpErrorFromResponse } from '@cyanheads/mcp-ts-core/utils';
|
|
320
|
+
|
|
321
|
+
const response = await fetch(url, { signal: ctx.signal });
|
|
322
|
+
if (!response.ok) {
|
|
323
|
+
throw await httpErrorFromResponse(response, {
|
|
324
|
+
service: 'NCBI', // included in message
|
|
325
|
+
data: { endpoint, requestId: ctx.requestId },
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
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.
|
|
331
|
+
|
|
332
|
+
> **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.
|
|
333
|
+
|
|
334
|
+
Full status table:
|
|
335
|
+
|
|
336
|
+
| Status | Code |
|
|
337
|
+
|:-------|:-----|
|
|
338
|
+
| 400 | `InvalidParams` |
|
|
339
|
+
| 401 | `Unauthorized` |
|
|
340
|
+
| 402, 403 | `Forbidden` |
|
|
341
|
+
| 404 | `NotFound` |
|
|
342
|
+
| 408, 425, 504 | `Timeout` |
|
|
343
|
+
| 409, 423, 424 | `Conflict` |
|
|
344
|
+
| 422 | `ValidationError` |
|
|
345
|
+
| 429 | `RateLimited` |
|
|
346
|
+
| 405, 406, 410, 412, 415, 416, 417, 428, 431, 451, 4xx (other) | `InvalidRequest` |
|
|
347
|
+
| 500, 501 | `InternalError` |
|
|
348
|
+
| 502, 503, 5xx (other) | `ServiceUnavailable` |
|
|
349
|
+
|
|
350
|
+
Also exports `httpStatusToErrorCode(status)` for sync mapping when you don't have a Response object.
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
## Handler-Body Lint Rules
|
|
355
|
+
|
|
356
|
+
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.
|
|
357
|
+
|
|
358
|
+
| Rule | Catches |
|
|
359
|
+
|:-----|:--------|
|
|
360
|
+
| `prefer-mcp-error-in-handler` | `throw new Error(...)` inside a handler — use `McpError` or a factory so the framework returns a specific code |
|
|
361
|
+
| `prefer-error-factory` | `new McpError(JsonRpcErrorCode.NotFound, ...)` when `notFound(...)` exists |
|
|
362
|
+
| `preserve-cause-on-rethrow` | `catch (e) { throw new McpError(...) }` without `{ cause: e }` |
|
|
363
|
+
| `no-stringify-upstream-error` | `JSON.stringify(...)` inside a thrown message — risks leaking internal traces; use `data` payload instead |
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
## Error Contract Lint Rules
|
|
368
|
+
|
|
369
|
+
The linter validates the structure of `errors[]` and (when present) cross-checks the handler body against the declared contract.
|
|
370
|
+
|
|
371
|
+
### Structural rules
|
|
372
|
+
|
|
373
|
+
| Rule | Severity | Catches |
|
|
374
|
+
|:-----|:---------|:--------|
|
|
375
|
+
| `error-contract-type` | error | `errors` is present but not an array |
|
|
376
|
+
| `error-contract-empty` | warning | `errors: []` — drop the field instead, or declare actual failure modes |
|
|
377
|
+
| `error-contract-entry-type` | error | An entry isn't an object |
|
|
378
|
+
| `error-contract-code-type` | error | `code` missing or not a number |
|
|
379
|
+
| `error-contract-code-unknown` | error | `code` isn't a real `JsonRpcErrorCode` value |
|
|
380
|
+
| `error-contract-code-unknown-error` | warning | `code` is `JsonRpcErrorCode.UnknownError` (the giveup-fallback — pick a more specific code) |
|
|
381
|
+
| `error-contract-reason-required` | error | `reason` missing or empty |
|
|
382
|
+
| `error-contract-reason-format` | warning | `reason` not snake_case |
|
|
383
|
+
| `error-contract-reason-unique` | error | Duplicate `reason` within one contract |
|
|
384
|
+
| `error-contract-when-required` | error | `when` missing or empty |
|
|
385
|
+
| `error-contract-retryable-type` | warning | `retryable` is present but not a boolean |
|
|
386
|
+
|
|
387
|
+
### Conformance rules
|
|
388
|
+
|
|
389
|
+
| Rule | Severity | Catches |
|
|
390
|
+
|:-----|:---------|:--------|
|
|
391
|
+
| `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. |
|
|
392
|
+
| `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. |
|
|
393
|
+
|
|
394
|
+
### Baseline codes (auto-allowed)
|
|
395
|
+
|
|
396
|
+
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:
|
|
397
|
+
|
|
398
|
+
- `InternalError` — bug, programmer error, truly unexpected
|
|
399
|
+
- `ServiceUnavailable` — upstream/network failures
|
|
400
|
+
- `Timeout` — request deadline exceeded, abort
|
|
401
|
+
- `ValidationError` — schema violations, malformed input
|
|
402
|
+
- `SerializationError` — JSON/XML parse failures
|
|
403
|
+
|
|
404
|
+
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.
|
|
405
|
+
|
|
406
|
+
### When to declare vs. let it bubble
|
|
407
|
+
|
|
408
|
+
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.
|
|
409
|
+
|
|
410
|
+
| Pattern | Use for |
|
|
411
|
+
|:--------|:--------|
|
|
412
|
+
| `throw ctx.fail('reason', …)` | Declared domain failures — typed, contract-checked, `data.reason` populated |
|
|
413
|
+
| `throw notFound(…)` / factories | Errors not in the contract; the auto-classifier handles them. Prefer `ctx.fail` when a matching contract entry exists. |
|
|
414
|
+
| Bubble up from services | Upstream classification already produced an `McpError` — don't re-wrap |
|
|
@@ -48,7 +48,11 @@ Grouped by family. Jump to any rule ID via its anchor.
|
|
|
48
48
|
| Names | `name-required`, `name-format`, `name-unique` | [Name rules](#name-rules) |
|
|
49
49
|
| Tools | `description-required`, `handler-required`, `auth-type`, `auth-scope-format`, `annotation-type`, `annotation-coherence`, `meta-ui-type`, `meta-ui-resource-uri-required`, `meta-ui-resource-uri-scheme`, `app-tool-resource-pairing` | [Tool rules](#tool-rules) |
|
|
50
50
|
| Resources | `uri-template-required`, `uri-template-valid`, `resource-name-not-uri`, `template-params-align` | [Resource rules](#resource-rules) |
|
|
51
|
+
| Landing | `landing-*` (23 rules — shape, tagline, logo, links, repo, envExample, connectSnippets, theme) | [Landing config rules](#landing-config-rules) |
|
|
51
52
|
| Prompts | `generate-required` | [Prompt rules](#prompt-rules) |
|
|
53
|
+
| Handler body | `prefer-mcp-error-in-handler`, `prefer-error-factory`, `preserve-cause-on-rethrow`, `no-stringify-upstream-error` | [Handler body rules](#handler-body-rules) |
|
|
54
|
+
| Error contract (structural) | `error-contract-type`, `error-contract-empty`, `error-contract-entry-type`, `error-contract-code-type`, `error-contract-code-unknown`, `error-contract-code-unknown-error`, `error-contract-reason-required`, `error-contract-reason-format`, `error-contract-reason-unique`, `error-contract-when-required`, `error-contract-retryable-type` | [Error contract rules](#error-contract-rules) |
|
|
55
|
+
| Error contract (conformance) | `error-contract-conformance`, `error-contract-prefer-fail` | [Error contract rules](#error-contract-rules) |
|
|
52
56
|
| server.json | ~40 rules prefixed `server-json-*` | [server.json rules](#server-json-rules) |
|
|
53
57
|
|
|
54
58
|
---
|
|
@@ -196,7 +200,9 @@ Every tool, resource, and prompt definition needs a non-empty `name` string. For
|
|
|
196
200
|
|
|
197
201
|
**Severity:** error
|
|
198
202
|
|
|
199
|
-
|
|
203
|
+
**Scope:** tools only — resources and prompts are checked by `name-required` only.
|
|
204
|
+
|
|
205
|
+
Tool names must match `^[A-Za-z0-9._-]{1,128}$` (alphanumerics, dots, hyphens, underscores; 1–128 chars). Tools conventionally use `snake_case`.
|
|
200
206
|
|
|
201
207
|
**Fix:** rename to a valid identifier. If the legacy name is user-facing, keep `title` as the display string and use a valid `name` internally.
|
|
202
208
|
|
|
@@ -259,7 +265,7 @@ Every element in `auth` must be a non-empty string. Empty strings in the array a
|
|
|
259
265
|
|
|
260
266
|
**Severity:** warning
|
|
261
267
|
|
|
262
|
-
|
|
268
|
+
Catches `readOnlyHint: true` with **any** explicit `destructiveHint` value (even `false`) — the destructive hint is meaningless on a read-only tool, so its presence signals authoring confusion. Drop `destructiveHint` entirely when the tool is read-only.
|
|
263
269
|
|
|
264
270
|
### meta-ui-type
|
|
265
271
|
|
|
@@ -322,7 +328,7 @@ resource('myscheme://{id}/data', {
|
|
|
322
328
|
|
|
323
329
|
**Severity:** error
|
|
324
330
|
|
|
325
|
-
Every variable in the URI template must appear as a key in the `params` schema
|
|
331
|
+
Every variable in the URI template must appear as a key in the `params` schema. `test://{itemId}/data` with `params: z.object({ item_id: ... })` is rejected — casing mismatches count. The check is template → schema only; extra schema keys not referenced by the template are not flagged.
|
|
326
332
|
|
|
327
333
|
**Fix:** rename one side so they match exactly. The error message names which variables are on which side.
|
|
328
334
|
|
|
@@ -391,6 +397,220 @@ Most of these are mechanical — fix the manifest field named in the diagnostic'
|
|
|
391
397
|
|
|
392
398
|
---
|
|
393
399
|
|
|
400
|
+
## Landing config rules
|
|
401
|
+
|
|
402
|
+
Validate the `landing` config passed to `createApp()` (the config object that drives the framework's landing page). Run only when `input.landing` is provided to `validateDefinitions`. All errors — landing config that's structurally broken would render incorrectly on the public page.
|
|
403
|
+
|
|
404
|
+
| Rule | Severity | Catches |
|
|
405
|
+
|:-----|:---------|:--------|
|
|
406
|
+
| `landing-shape` | error | `landing` is not a plain object |
|
|
407
|
+
| `landing-tagline-type` | error | `tagline` is present but not a string |
|
|
408
|
+
| `landing-tagline-length` | error | `tagline` exceeds the max length |
|
|
409
|
+
| `landing-logo-type` | error | `logo` is present but not a string |
|
|
410
|
+
| `landing-logo-size` | error | `logo` is too long for inline rendering |
|
|
411
|
+
| `landing-links-type` | error | `links` is present but not an array |
|
|
412
|
+
| `landing-links-count` | error | `links` exceeds the max count |
|
|
413
|
+
| `landing-link-shape` | error | A `links[]` entry is not a plain object |
|
|
414
|
+
| `landing-link-href` | error | A link entry's `href` is missing or not a non-empty string |
|
|
415
|
+
| `landing-link-label` | error | A link entry's `label` is missing or not a non-empty string |
|
|
416
|
+
| `landing-repo-root-type` | error | `repoRoot` is present but not a string |
|
|
417
|
+
| `landing-repo-root-shape` | error | `repoRoot` is not a recognized GitHub URL shape |
|
|
418
|
+
| `landing-env-example-type` | error | `envExample` is present but not a plain object |
|
|
419
|
+
| `landing-env-example-count` | error | `envExample` has too many entries |
|
|
420
|
+
| `landing-env-example-key` | error | An `envExample` key is empty or invalid |
|
|
421
|
+
| `landing-env-example-value` | error | An `envExample` value is not a string |
|
|
422
|
+
| `landing-connect-snippets-type` | error | `connectSnippets` is present but not a plain object |
|
|
423
|
+
| `landing-connect-snippets-key` | error | A `connectSnippets` key is empty |
|
|
424
|
+
| `landing-connect-snippets-value` | error | A `connectSnippets` value is not a string |
|
|
425
|
+
| `landing-connect-snippets-empty` | error | A `connectSnippets` value is an empty string |
|
|
426
|
+
| `landing-theme-type` | error | `theme` is present but not a plain object |
|
|
427
|
+
| `landing-theme-accent` | error | `theme.accent` is present but not a string |
|
|
428
|
+
| `landing-theme-accent-format` | error | `theme.accent` doesn't match the expected color format |
|
|
429
|
+
|
|
430
|
+
Diagnostic anchors for these rules are the rule ID — e.g. `skills/api-linter/SKILL.md#landing-shape`. Pass `landing` to `validateDefinitions({ landing, tools, resources, prompts })` to opt in.
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
## Handler body rules
|
|
435
|
+
|
|
436
|
+
Heuristic source-text checks that scan `handler.toString()` for common error-handling anti-patterns. All warnings — false positives are possible because the rules can't see code reached through wrappers, factories assigned to variables, or service-layer throws. Each rule fires at most once per handler to keep reports quiet.
|
|
437
|
+
|
|
438
|
+
### prefer-mcp-error-in-handler
|
|
439
|
+
|
|
440
|
+
**Severity:** warning
|
|
441
|
+
|
|
442
|
+
Fires when a handler contains `throw new Error(...)`. Plain `Error` doesn't carry a JSON-RPC code — the framework's auto-classifier degrades to `InternalError`, hiding the actual failure mode.
|
|
443
|
+
|
|
444
|
+
**Fix:** use `McpError` or a factory:
|
|
445
|
+
|
|
446
|
+
```ts
|
|
447
|
+
// instead of:
|
|
448
|
+
throw new Error('Item not found');
|
|
449
|
+
// use:
|
|
450
|
+
throw notFound('Item not found', { itemId });
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
### prefer-error-factory
|
|
454
|
+
|
|
455
|
+
**Severity:** warning
|
|
456
|
+
|
|
457
|
+
Fires when a handler builds an error via `new McpError(JsonRpcErrorCode.X, ...)` and a matching factory exists (`notFound`, `rateLimited`, `serviceUnavailable`, …). The factory form is shorter, self-documenting, and consistent with the rest of the codebase.
|
|
458
|
+
|
|
459
|
+
**Fix:** swap the constructor for the factory the diagnostic names:
|
|
460
|
+
|
|
461
|
+
```ts
|
|
462
|
+
// instead of:
|
|
463
|
+
throw new McpError(JsonRpcErrorCode.NotFound, 'Item missing');
|
|
464
|
+
// use:
|
|
465
|
+
throw notFound('Item missing');
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
### preserve-cause-on-rethrow
|
|
469
|
+
|
|
470
|
+
**Severity:** warning
|
|
471
|
+
|
|
472
|
+
Fires when a `catch (e)` block throws a structured `McpError` (or factory) without passing `{ cause: e }`. Dropping the cause loses the original stack trace — observability platforms and `pino-pretty` rely on it to render error chains.
|
|
473
|
+
|
|
474
|
+
**Fix:** thread the cause through the 4th `McpError` argument or factory options:
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
try {
|
|
478
|
+
await fetchUpstream();
|
|
479
|
+
} catch (e) {
|
|
480
|
+
throw serviceUnavailable('Upstream failed', { service: 'pubmed' }, { cause: e });
|
|
481
|
+
}
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
### no-stringify-upstream-error
|
|
485
|
+
|
|
486
|
+
**Severity:** warning
|
|
487
|
+
|
|
488
|
+
Fires when a handler throws an error message containing `JSON.stringify(...)`. Stringifying caught or upstream errors into the message risks leaking internal stack traces, AWS internal ARNs, or third-party trace IDs to clients.
|
|
489
|
+
|
|
490
|
+
**Fix:** sanitize first, or attach the raw blob to the error's `data` payload — never the message.
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
// instead of:
|
|
494
|
+
throw new Error(`Upstream failed: ${JSON.stringify(e)}`);
|
|
495
|
+
// use:
|
|
496
|
+
throw serviceUnavailable('Upstream failed', { upstreamError: e }, { cause: e });
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
---
|
|
500
|
+
|
|
501
|
+
## Error contract rules
|
|
502
|
+
|
|
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
|
+
|
|
505
|
+
When a contract is declared, surfaced under `_meta['mcp-ts-core/errors']` in `tools/list` / `resources/list`, and the handler receives a typed `ctx.fail(reason, …)` keyed by the declared reason union. See `skills/api-errors/SKILL.md` for runtime semantics.
|
|
506
|
+
|
|
507
|
+
### error-contract-type
|
|
508
|
+
|
|
509
|
+
**Severity:** error
|
|
510
|
+
|
|
511
|
+
Fires when `errors` is present but not an array. The contract must be a tuple of `ErrorContract` entries.
|
|
512
|
+
|
|
513
|
+
### error-contract-empty
|
|
514
|
+
|
|
515
|
+
**Severity:** warning
|
|
516
|
+
|
|
517
|
+
Fires when `errors: []` is declared. An empty contract is a no-op — nothing to surface in `tools/list`, no reason union for `ctx.fail`, no conformance to check.
|
|
518
|
+
|
|
519
|
+
**Fix:** drop the field, or declare actual failure modes.
|
|
520
|
+
|
|
521
|
+
### error-contract-entry-type
|
|
522
|
+
|
|
523
|
+
**Severity:** error
|
|
524
|
+
|
|
525
|
+
Fires when an entry in `errors[]` isn't an object. Each entry must be `{ code, reason, when }` (and optionally `retryable`).
|
|
526
|
+
|
|
527
|
+
### error-contract-code-type
|
|
528
|
+
|
|
529
|
+
**Severity:** error
|
|
530
|
+
|
|
531
|
+
Fires when an entry's `code` is missing or not a number. Use the `JsonRpcErrorCode` enum:
|
|
532
|
+
|
|
533
|
+
```ts
|
|
534
|
+
errors: [{ code: JsonRpcErrorCode.NotFound, reason: 'no_match', when: 'No items matched' }]
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
### error-contract-code-unknown
|
|
538
|
+
|
|
539
|
+
**Severity:** error
|
|
540
|
+
|
|
541
|
+
Fires when an entry's `code` is a number but not a known `JsonRpcErrorCode` value. Likely a typo or stale magic number — import the enum and use a member.
|
|
542
|
+
|
|
543
|
+
### error-contract-code-unknown-error
|
|
544
|
+
|
|
545
|
+
**Severity:** warning
|
|
546
|
+
|
|
547
|
+
Fires when an entry uses `JsonRpcErrorCode.UnknownError` (-32099). That code is the auto-classifier's giveup-fallback; declaring it in a contract conveys nothing useful to clients.
|
|
548
|
+
|
|
549
|
+
**Fix:** pick a more specific code (`InternalError`, `ServiceUnavailable`, etc.) or drop the entry.
|
|
550
|
+
|
|
551
|
+
### error-contract-reason-required
|
|
552
|
+
|
|
553
|
+
**Severity:** error
|
|
554
|
+
|
|
555
|
+
Fires when an entry's `reason` is missing or empty. `reason` is the stable machine-readable identifier clients switch on; it must always be present.
|
|
556
|
+
|
|
557
|
+
### error-contract-reason-format
|
|
558
|
+
|
|
559
|
+
**Severity:** warning
|
|
560
|
+
|
|
561
|
+
Fires when `reason` isn't snake_case (matched against `^[a-z][a-z0-9_]*$`). Reasons are part of the public API — treat them like API constants. `'NotFound'`, `'no-match'`, `'1bad'` all warn.
|
|
562
|
+
|
|
563
|
+
**Fix:** rename to snake_case (`'no_match'`, `'rate_limited'`, …).
|
|
564
|
+
|
|
565
|
+
### error-contract-reason-unique
|
|
566
|
+
|
|
567
|
+
**Severity:** error
|
|
568
|
+
|
|
569
|
+
Fires when two entries in the same contract share a `reason`. Reasons must be unique within a contract — they're how `ctx.fail(reason, …)` selects the entry.
|
|
570
|
+
|
|
571
|
+
### error-contract-when-required
|
|
572
|
+
|
|
573
|
+
**Severity:** error
|
|
574
|
+
|
|
575
|
+
Fires when an entry's `when` field is missing or empty. `when` is the human-readable explanation surfaced to LLMs and UI clients; without it, the contract is opaque.
|
|
576
|
+
|
|
577
|
+
### error-contract-retryable-type
|
|
578
|
+
|
|
579
|
+
**Severity:** warning
|
|
580
|
+
|
|
581
|
+
Fires when an entry's optional `retryable` field is present but isn't a boolean. Only `true` or `false` is meaningful — drop the field if you can't commit to either.
|
|
582
|
+
|
|
583
|
+
### error-contract-conformance
|
|
584
|
+
|
|
585
|
+
**Severity:** warning
|
|
586
|
+
|
|
587
|
+
Cross-check rule. Fires when a handler throws a non-baseline code (via `JsonRpcErrorCode.X` or a factory like `notFound()`) that isn't declared in `errors[]`.
|
|
588
|
+
|
|
589
|
+
Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) are auto-allowed because they bubble from anywhere — services, framework utilities, the auto-classifier — and are implicitly always-possible on any tool. Only domain-specific codes need declaring.
|
|
590
|
+
|
|
591
|
+
**Fix:** add the missing code to `errors[]` with a stable reason, or route through `ctx.fail(reason, …)` if it maps to an existing entry.
|
|
592
|
+
|
|
593
|
+
**Heuristic limitations:** the scan reads `handler.toString()` and only catches direct `throw new McpError(JsonRpcErrorCode.X, …)` and `throw factory(…)` patterns. Indirect throws (`const e = notFound(); throw e;`), throws from called services, and throws via runtime helpers like `httpErrorFromResponse(...)` are invisible.
|
|
594
|
+
|
|
595
|
+
### error-contract-prefer-fail
|
|
596
|
+
|
|
597
|
+
**Severity:** warning
|
|
598
|
+
|
|
599
|
+
Fires when a handler throws a code that **is** declared in the contract directly (via factory or `new McpError`) instead of routing through `ctx.fail(reason, …)`. Direct throws bypass the typed helper, leaving observers without a stable `data.reason` and disconnecting the throw site from the contract entry.
|
|
600
|
+
|
|
601
|
+
**Fix:** swap the direct throw for `ctx.fail` using the reason the diagnostic suggests:
|
|
602
|
+
|
|
603
|
+
```ts
|
|
604
|
+
// instead of:
|
|
605
|
+
throw notFound('No items match');
|
|
606
|
+
// use:
|
|
607
|
+
throw ctx.fail('no_match', 'No items match');
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
The diagnostic message includes the declared reason(s) for the code so you can copy-paste.
|
|
611
|
+
|
|
612
|
+
---
|
|
613
|
+
|
|
394
614
|
## Escape hatches
|
|
395
615
|
|
|
396
616
|
### Dynamic upstream data
|