@cyanheads/mcp-ts-core 0.13.9 → 0.13.11
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/AGENTS.md +41 -15
- package/CLAUDE.md +41 -15
- package/README.md +37 -11
- package/biome.json +1 -1
- package/changelog/0.13.x/0.13.10.md +118 -0
- package/changelog/0.13.x/0.13.11.md +113 -0
- package/dist/cli/app-render.d.ts +11 -0
- package/dist/cli/app-render.d.ts.map +1 -0
- package/dist/cli/app-render.js +150 -0
- package/dist/cli/app-render.js.map +1 -0
- package/dist/cli/init.d.ts +2 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +10 -2
- package/dist/cli/init.js.map +1 -1
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +11 -0
- package/dist/config/index.js.map +1 -1
- package/dist/core/app.d.ts.map +1 -1
- package/dist/core/app.js +15 -1
- package/dist/core/app.js.map +1 -1
- package/dist/core/context.d.ts +92 -22
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +65 -16
- package/dist/core/context.js.map +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/worker.d.ts +6 -0
- package/dist/core/worker.d.ts.map +1 -1
- package/dist/core/worker.js +1 -0
- package/dist/core/worker.js.map +1 -1
- package/dist/linter/rules/error-contract-rules.d.ts +3 -44
- package/dist/linter/rules/error-contract-rules.d.ts.map +1 -1
- package/dist/linter/rules/error-contract-rules.js +8 -144
- package/dist/linter/rules/error-contract-rules.js.map +1 -1
- package/dist/linter/rules/index.d.ts +1 -1
- package/dist/linter/rules/index.d.ts.map +1 -1
- package/dist/linter/rules/index.js +1 -1
- 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 +1 -2
- package/dist/linter/rules/resource-rules.js.map +1 -1
- package/dist/linter/rules/tool-rules.d.ts.map +1 -1
- package/dist/linter/rules/tool-rules.js +1 -2
- package/dist/linter/rules/tool-rules.js.map +1 -1
- package/dist/mcp-server/handlerContext.d.ts +26 -13
- package/dist/mcp-server/handlerContext.d.ts.map +1 -1
- package/dist/mcp-server/handlerContext.js +32 -17
- package/dist/mcp-server/handlerContext.js.map +1 -1
- package/dist/mcp-server/inputRequired.d.ts +120 -8
- package/dist/mcp-server/inputRequired.d.ts.map +1 -1
- package/dist/mcp-server/inputRequired.js +177 -12
- package/dist/mcp-server/inputRequired.js.map +1 -1
- package/dist/mcp-server/prompts/prompt-registration.d.ts +10 -2
- package/dist/mcp-server/prompts/prompt-registration.d.ts.map +1 -1
- package/dist/mcp-server/prompts/prompt-registration.js +49 -11
- package/dist/mcp-server/prompts/prompt-registration.js.map +1 -1
- package/dist/mcp-server/resources/resource-registration.d.ts +4 -2
- package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
- package/dist/mcp-server/resources/resource-registration.js +6 -4
- package/dist/mcp-server/resources/resource-registration.js.map +1 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts +4 -3
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +60 -21
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
- package/dist/mcp-server/server.d.ts +9 -0
- package/dist/mcp-server/server.d.ts.map +1 -1
- package/dist/mcp-server/server.js +14 -13
- package/dist/mcp-server/server.js.map +1 -1
- package/dist/mcp-server/tools/tool-registration.d.ts +7 -3
- package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
- package/dist/mcp-server/tools/tool-registration.js +9 -5
- package/dist/mcp-server/tools/tool-registration.js.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +17 -9
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js +91 -50
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
- package/dist/mcp-server/transports/http/httpErrorHandler.d.ts.map +1 -1
- package/dist/mcp-server/transports/http/httpErrorHandler.js +7 -1
- package/dist/mcp-server/transports/http/httpErrorHandler.js.map +1 -1
- package/dist/services/mirror/sqlite/handle.d.ts +11 -5
- package/dist/services/mirror/sqlite/handle.d.ts.map +1 -1
- package/dist/services/mirror/sqlite/handle.js +46 -8
- package/dist/services/mirror/sqlite/handle.js.map +1 -1
- package/dist/services/mirror/sqlite/sqliteMirrorStore.d.ts +6 -1
- package/dist/services/mirror/sqlite/sqliteMirrorStore.d.ts.map +1 -1
- package/dist/services/mirror/sqlite/sqliteMirrorStore.js.map +1 -1
- package/dist/testing/apps/browser.d.ts +27 -0
- package/dist/testing/apps/browser.d.ts.map +1 -0
- package/dist/testing/apps/browser.js +162 -0
- package/dist/testing/apps/browser.js.map +1 -0
- package/dist/testing/apps/cdp-pipe.d.ts +79 -0
- package/dist/testing/apps/cdp-pipe.d.ts.map +1 -0
- package/dist/testing/apps/cdp-pipe.js +200 -0
- package/dist/testing/apps/cdp-pipe.js.map +1 -0
- package/dist/testing/apps/csp.d.ts +17 -0
- package/dist/testing/apps/csp.d.ts.map +1 -0
- package/dist/testing/apps/csp.js +50 -0
- package/dist/testing/apps/csp.js.map +1 -0
- package/dist/testing/apps/host-pages.d.ts +47 -0
- package/dist/testing/apps/host-pages.d.ts.map +1 -0
- package/dist/testing/apps/host-pages.js +138 -0
- package/dist/testing/apps/host-pages.js.map +1 -0
- package/dist/testing/apps/index.d.ts +167 -0
- package/dist/testing/apps/index.d.ts.map +1 -0
- package/dist/testing/apps/index.js +36 -0
- package/dist/testing/apps/index.js.map +1 -0
- package/dist/testing/apps/partial-json.d.ts +14 -0
- package/dist/testing/apps/partial-json.d.ts.map +1 -0
- package/dist/testing/apps/partial-json.js +71 -0
- package/dist/testing/apps/partial-json.js.map +1 -0
- package/dist/testing/apps/run.d.ts +13 -0
- package/dist/testing/apps/run.d.ts.map +1 -0
- package/dist/testing/apps/run.js +659 -0
- package/dist/testing/apps/run.js.map +1 -0
- package/dist/testing/index.d.ts +17 -2
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +21 -7
- package/dist/testing/index.js.map +1 -1
- package/dist/types-global/errors.d.ts +18 -15
- package/dist/types-global/errors.d.ts.map +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/internal/error-handler/errorHandler.d.ts +5 -4
- package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
- package/dist/utils/internal/error-handler/errorHandler.js +9 -7
- package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
- package/dist/utils/internal/error-handler/types.d.ts +3 -1
- package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
- package/dist/utils/internal/logger.d.ts +21 -1
- package/dist/utils/internal/logger.d.ts.map +1 -1
- package/dist/utils/internal/logger.js +31 -14
- package/dist/utils/internal/logger.js.map +1 -1
- package/dist/utils/internal/performance.d.ts +7 -3
- package/dist/utils/internal/performance.d.ts.map +1 -1
- package/dist/utils/internal/performance.js +16 -9
- package/dist/utils/internal/performance.js.map +1 -1
- package/dist/utils/internal/telemetryMessages.d.ts +0 -1
- package/dist/utils/internal/telemetryMessages.d.ts.map +1 -1
- package/dist/utils/internal/telemetryMessages.js +0 -1
- package/dist/utils/internal/telemetryMessages.js.map +1 -1
- package/dist/utils/security/sanitization.d.ts +16 -13
- package/dist/utils/security/sanitization.d.ts.map +1 -1
- package/dist/utils/security/sanitization.js +74 -33
- package/dist/utils/security/sanitization.js.map +1 -1
- package/dist/utils/telemetry/attributes.d.ts +12 -5
- package/dist/utils/telemetry/attributes.d.ts.map +1 -1
- package/dist/utils/telemetry/attributes.js +12 -5
- package/dist/utils/telemetry/attributes.js.map +1 -1
- package/framework-skills/add-app-tool/SKILL.md +2 -1
- package/framework-skills/add-service/SKILL.md +3 -12
- package/framework-skills/add-test/SKILL.md +20 -17
- package/framework-skills/add-tool/SKILL.md +30 -34
- package/framework-skills/api-config/SKILL.md +12 -2
- package/framework-skills/api-context/SKILL.md +156 -41
- package/framework-skills/api-errors/SKILL.md +43 -47
- package/framework-skills/api-linter/SKILL.md +5 -29
- package/framework-skills/api-mirror/SKILL.md +4 -3
- package/framework-skills/api-telemetry/SKILL.md +16 -10
- package/framework-skills/api-testing/SKILL.md +47 -11
- package/framework-skills/api-utils/SKILL.md +3 -3
- package/framework-skills/api-workers/SKILL.md +3 -1
- package/framework-skills/code-simplifier/SKILL.md +2 -2
- package/framework-skills/design-mcp-server/SKILL.md +5 -5
- package/framework-skills/field-test/SKILL.md +53 -8
- package/framework-skills/git-wrapup/SKILL.md +2 -2
- package/framework-skills/orchestrations/SKILL.md +1 -1
- package/framework-skills/orchestrations/workflows/greenfield-build.md +2 -2
- package/framework-skills/polish-docs-meta/SKILL.md +4 -3
- package/framework-skills/polish-docs-meta/references/server-json.md +24 -26
- package/framework-skills/release-and-publish/SKILL.md +3 -3
- package/framework-skills/release-pr-review/SKILL.md +6 -6
- package/framework-skills/security-pass/SKILL.md +8 -7
- package/framework-skills/setup/SKILL.md +2 -2
- package/package.json +38 -22
- package/scripts/devcheck.ts +43 -21
- package/scripts/install-otel.ts +84 -0
- package/scripts/lint-packaging.ts +279 -28
- package/scripts/prune-musl-packages.ts +146 -0
- package/templates/.env.example +2 -0
- package/templates/.github/workflows/codeql.yml +10 -1
- package/templates/AGENTS.md +5 -4
- package/templates/CLAUDE.md +5 -4
- package/templates/Dockerfile +76 -50
- package/templates/_.dockerignore +3 -5
- package/templates/_.gitignore +4 -4
- package/templates/package.json +4 -4
- package/templates/server.json +7 -21
- package/templates/src/mcp-server/tools/definitions/echo.tool.ts +3 -8
|
@@ -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.19"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -64,16 +64,11 @@ export const fetchTool = tool('fetch_articles', {
|
|
|
64
64
|
|:--------|:---------|
|
|
65
65
|
| Compile time | `ctx.fail('typo')` is a TS error. Auto-completes declared reasons. |
|
|
66
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. |
|
|
67
|
+
| Runtime (recovery) | A failure whose `data.reason` names a declared entry and carries no `data.recovery` gets `data.recovery.hint` set to the entry's `recovery` at the handler boundary — see below. |
|
|
67
68
|
| Lint (devcheck) | Each `code` validated against `JsonRpcErrorCode`. Reasons validated as snake_case + unique within contract. `recovery` validated as non-empty and ≥ 5 words. Build-time only — not invoked at server startup. |
|
|
68
|
-
| Lint (conformance) | If the handler `throw new McpError(JsonRpcErrorCode.X)` outside `ctx.fail`, conformance check warns when X isn't declared. The inverse is checked too: a declared reason no `ctx.fail` in the handler names warns as `error-contract-unthrown` (mark it `thrownBy: 'service'` when the service layer produces it)
|
|
69
|
+
| Lint (conformance) | If the handler `throw new McpError(JsonRpcErrorCode.X)` outside `ctx.fail`, conformance check warns when X isn't declared. The inverse is checked too: a declared reason no `ctx.fail` in the handler names warns as `error-contract-unthrown` (mark it `thrownBy: 'service'` when the service layer produces it). |
|
|
69
70
|
|
|
70
|
-
> **`recovery` is
|
|
71
|
-
|
|
72
|
-
#### `ctx.recoveryFor` — opt-in contract resolution
|
|
73
|
-
|
|
74
|
-
`ctx.recoveryFor(reason)` returns `{ recovery: { hint: <contract.recovery> } }` for a declared reason, ready to spread into `data`. Always available on `Context` (returns `{}` when no contract is attached or the reason is unknown — spread-safe with no optional chaining). On `HandlerContext<R>` it tightens to a typed signature constrained to the declared reason union.
|
|
75
|
-
|
|
76
|
-
Spreading it into the data object and passing it as the data argument are the same call — `ctx.fail` spreads whatever `data` it receives. Spread when the site carries other keys, pass it directly when it carries nothing else. **Forwarding is lint-enforced per throw site:** a `ctx.fail` site that carries neither the resolver nor its own `recovery` key warns as `error-contract-recovery-unforwarded`, because the declared hint then reaches neither client surface and an error-path test asserting `code` and `reason` still passes.
|
|
71
|
+
> **`recovery` is the wire default for its reason.** 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), and it is what the caller receives. When a failure whose `data.reason` names a declared entry reaches the tool or resource handler factory with no `data.recovery`, the factory sets `data.recovery.hint` to that entry's `recovery` before it logs the failure and builds the envelope, so the `Error in tool:<name>` record, `structuredContent.error.data`, and the `Recovery:` line in `content[]` carry the same hint. It matches on the reason alone — a bare `ctx.fail('reason')`, a service throwing `notFound(msg, { reason })`, and a declared reason raised through a factory with a different code all get it. A throw-site `recovery` always wins, whatever its shape. An undeclared reason, a tool without `errors[]`, a non-`McpError` throw, and a cancelled call get nothing, and the framework-owned `invalid_arguments` / `client_capability_missing` refusals keep their own hints. The thrown `McpError` is never changed — a handler-level test of `ctx.fail` sees exactly what the throw site wrote — and `runToolContract` applies the same fill, so a contract test sees the production envelope. Prompts declare no contract.
|
|
77
72
|
|
|
78
73
|
```ts
|
|
79
74
|
export const calculateTool = tool('calculate', {
|
|
@@ -85,32 +80,29 @@ export const calculateTool = tool('calculate', {
|
|
|
85
80
|
],
|
|
86
81
|
handler(input, ctx) {
|
|
87
82
|
if (!input.expression.trim()) {
|
|
88
|
-
// Static recovery —
|
|
89
|
-
throw ctx.fail('empty_expression'
|
|
83
|
+
// Static recovery — the framework fills the contract's hint onto the wire.
|
|
84
|
+
throw ctx.fail('empty_expression');
|
|
90
85
|
}
|
|
91
86
|
// ...
|
|
92
87
|
},
|
|
93
88
|
});
|
|
94
89
|
```
|
|
95
90
|
|
|
96
|
-
Same
|
|
91
|
+
Same for a service, which needs no `ctx` to get the hint — the reason is enough:
|
|
97
92
|
|
|
98
93
|
```ts
|
|
99
94
|
export class MathService {
|
|
100
|
-
parse(expr: string
|
|
95
|
+
parse(expr: string) {
|
|
101
96
|
try {
|
|
102
97
|
return mathjs.parse(expr);
|
|
103
98
|
} catch (err) {
|
|
104
|
-
throw validationError(`Parse failed: ${err.message}`, {
|
|
105
|
-
reason: 'parse_failed',
|
|
106
|
-
...ctx.recoveryFor('parse_failed'), // {} if calling tool has no matching reason
|
|
107
|
-
});
|
|
99
|
+
throw validationError(`Parse failed: ${err.message}`, { reason: 'parse_failed' });
|
|
108
100
|
}
|
|
109
101
|
}
|
|
110
102
|
}
|
|
111
103
|
```
|
|
112
104
|
|
|
113
|
-
The contract is the single source of truth — write the recovery once, lint validates ≥5 words, the
|
|
105
|
+
The contract is the single source of truth — write the recovery once, lint validates ≥5 words, and the framework carries it to every failure with that reason. For runtime-context recovery (interpolating input values, attempted IDs, queue state), override at the throw site:
|
|
114
106
|
|
|
115
107
|
```ts
|
|
116
108
|
throw ctx.fail('no_match', `No item ${id}`, {
|
|
@@ -120,7 +112,11 @@ throw ctx.fail('no_match', `No item ${id}`, {
|
|
|
120
112
|
|
|
121
113
|
> **A recovery hint names a capability, never an internal method.** The reader is a model whose only reachable surface is this server's tool names — it cannot call a TypeScript method, set a library option, or re-run an internal function. `Re-stage the table via registerTable()` is unfollowable and invites a hallucinated tool call; `Re-run the tool that produced this table to stage it again, or list the currently staged tables with this server's dataframe-describe tool` is actionable from where the reader sits. Name a condition the caller cannot observe — an option flag they never set — and the hint is noise for the same reason. The framework holds its own throws to this rule: the canvas SQL gate's rejections point at the dataframe-query and dataframe-describe capabilities rather than the provider methods behind them.
|
|
122
114
|
|
|
123
|
-
`ctx.recoveryFor`
|
|
115
|
+
#### `ctx.recoveryFor` — the entry's hint at the throw site
|
|
116
|
+
|
|
117
|
+
`ctx.recoveryFor(reason)` returns `{ recovery: { hint: <contract.recovery> } }` for a declared reason, ready to spread into `data`. Always available on `Context` (returns `{}` when no contract is attached or the reason is unknown — spread-safe with no optional chaining). On `HandlerContext<R>` it tightens to a typed signature constrained to the declared reason union.
|
|
118
|
+
|
|
119
|
+
It is not needed to put a declared hint on the wire — the fill above does that. Reach for it when the hint has to ride the thrown error itself: a test asserting `data.recovery` on the handler's own throw, or a site that deliberately sends another entry's guidance (`ctx.fail('a', msg, ctx.recoveryFor('b'))`), which the fill respects as authored.
|
|
124
120
|
|
|
125
121
|
#### `severity` — log a modeled outcome below `error`
|
|
126
122
|
|
|
@@ -138,12 +134,14 @@ Values are the logger's own level names below `error` — `debug`, `info`, `noti
|
|
|
138
134
|
|
|
139
135
|
| Surface | Under a declared severity |
|
|
140
136
|
|:--------|:--------------------------|
|
|
141
|
-
| The `Error in tool:<name>` log record | Emitted at the declared level. Same message, same structured fields. |
|
|
137
|
+
| The `Error in tool:<name>` log record | Emitted at the declared level. Same message, same structured fields, the stack included. |
|
|
142
138
|
| `mcp.errors.classified` | Gains an `mcp.error.severity` attribute. The `reason` itself never becomes a metric attribute. |
|
|
143
139
|
| `isError`, the JSON-RPC code, `structuredContent.error`, `content[]` | Byte-identical to the undeclared case. |
|
|
144
140
|
| Span status, `mcp.tool.calls`, `mcp.tool.duration`, `mcp.tool.errors` | Unchanged — the call still failed, and splitting those series would redefine what an error rate means. |
|
|
145
141
|
|
|
146
|
-
**Tools only.** Resolution happens in the tool handler factory, against the thrown error's `data.reason`. Resources declare `errors[]` but
|
|
142
|
+
**Tools only.** Resolution happens in the tool handler factory, against the thrown error's `data.reason` — the same reason-to-entry lookup that fills `data.recovery`. Resources declare `errors[]` but write no failure record, so the field is accepted there and inert. A reason thrown below the handler that the contract never declared, an entry with no `severity`, and a non-`McpError` throw all keep `error`. A cancelled request keeps its own `info`, stack-free path regardless.
|
|
143
|
+
|
|
144
|
+
**The framework's own refusals log at `notice`.** An argument rejection (`invalid_arguments`, raised only by the schema gate before the handler runs) and a `ctx.requestInput` the connection cannot serve (`client_capability_missing`) are routine caller or connection traffic, not server faults, so their `Error in tool:<name>` record — and the failure-payload record when `LOG_TOOL_FAILURE_PAYLOADS=true` — is emitted at `notice`, and `mcp.errors.classified` counts them with `mcp.error.severity: "notice"`. Nothing to declare; an `errors[]` entry naming either reason with its own `severity` still wins. The wire envelope and `mcp.tool.rejections` are unchanged, and a schema that wrongly rejects valid calls still shows per tool on `mcp.tool.rejections`.
|
|
147
145
|
|
|
148
146
|
**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.
|
|
149
147
|
|
|
@@ -173,7 +171,7 @@ errors: [
|
|
|
173
171
|
]
|
|
174
172
|
```
|
|
175
173
|
|
|
176
|
-
The handler doesn't catch and re-throw — letting service errors bubble unchanged keeps "logic throws, framework catches" intact. The wire payload
|
|
174
|
+
The handler doesn't catch and re-throw — letting service errors bubble unchanged keeps "logic throws, framework catches" intact. The wire payload carries `code`, `data.reason`, and the declared entry's `recovery` as `data.recovery.hint` (filled at the handler boundary, whatever code the service picked), so 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.
|
|
177
175
|
|
|
178
176
|
**Mark the entries the service produces.** `error-contract-unthrown` reads the handler body alone, so in a handler that mixes one local precondition with service-thrown reasons it flags each service reason as dead. Add `thrownBy: 'service'` to those entries:
|
|
179
177
|
|
|
@@ -186,18 +184,7 @@ errors: [
|
|
|
186
184
|
]
|
|
187
185
|
```
|
|
188
186
|
|
|
189
|
-
The field is lint-only metadata — nothing at runtime reads it, so the entry is typed, advertised, and thrown exactly as an unmarked one, and its reason stays in the `ctx.fail` / `ctx.recoveryFor` union. It suppresses the one rule that cannot see below the handler, and only for the entries it marks; the handler's own reasons keep being checked.
|
|
190
|
-
|
|
191
|
-
To carry the contract `recovery` from a service throw, accept `ctx` and spread the resolver:
|
|
192
|
-
|
|
193
|
-
```ts
|
|
194
|
-
throw validationError(message, {
|
|
195
|
-
reason: 'parse_failed',
|
|
196
|
-
...ctx.recoveryFor('parse_failed'), // {} when calling tool has no matching reason
|
|
197
|
-
});
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
`ctx.recoveryFor` is always present on `Context` (no-op when no contract), so services don't need to know which tool called them — the spread is safe either way.
|
|
187
|
+
The field is lint-only metadata — nothing at runtime reads it, so the entry is typed, advertised, and thrown exactly as an unmarked one (its `recovery` filled like any other), and its reason stays in the `ctx.fail` / `ctx.recoveryFor` union. It suppresses the one rule that cannot see below the handler, and only for the entries it marks; the handler's own reasons keep being checked.
|
|
201
188
|
|
|
202
189
|
---
|
|
203
190
|
|
|
@@ -395,28 +382,38 @@ Checked before common patterns. Cover: AWS exception names, HTTP status codes, D
|
|
|
395
382
|
| Layer | Pattern |
|
|
396
383
|
|:------|:--------|
|
|
397
384
|
| Tool/resource handlers | Throw `McpError` — no try/catch |
|
|
398
|
-
| 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)) |
|
|
399
|
-
| Handler factory (resources) | Catches and re-throws to the SDK, which routes through the JSON-RPC error envelope |
|
|
385
|
+
| Handler factory (tools) | Catches all errors, fills a declared `recovery`, normalizes to `McpError`, sets `isError: true`, adds `data.requestId`, mirrors error across both client surfaces (see [Error-path parity](#error-path-parity)) |
|
|
386
|
+
| Handler factory (resources) | Catches, fills a declared `recovery`, adds `data.requestId`, and re-throws to the SDK, which routes through the JSON-RPC error envelope |
|
|
387
|
+
| Prompt registration, HTTP transport | Log the failure, then answer the JSON-RPC error with the thrown `McpError`'s `data` plus `data.requestId` |
|
|
400
388
|
| Services/setup code | `ErrorHandler.tryCatch` for structured logging and wrapping (always rethrows — never swallows) |
|
|
401
389
|
|
|
402
390
|
### Error-path parity
|
|
403
391
|
|
|
404
|
-
MCP clients differ in which `CallToolResult` surface they forward to the agent. Tool errors mirror the success-path `format-parity` invariant — the text carries the message, the recovery hint,
|
|
392
|
+
MCP clients differ in which `CallToolResult` surface they forward to the agent. Tool errors mirror the success-path `format-parity` invariant — the text carries the message, the recovery hint, the two fields a caller branches on, and the request id, while the numeric `code` and `data.issues` stay JSON-only:
|
|
405
393
|
|
|
406
394
|
| Surface | Content | Read by |
|
|
407
395
|
|:--------|:--------|:--------|
|
|
408
|
-
| `content[]` | Text rendering: `Error: <message>`, then `Recovery: <hint>` when `data.recovery.hint` adds something the message does not already say, then `(reason <reason> · not retryable)` for whichever of `data.reason` / `data.retryable` is present | Claude Desktop and other format()-only clients |
|
|
409
|
-
| `structuredContent.error` | JSON `{ code, message, data? }` carrying the error code, message,
|
|
396
|
+
| `content[]` | Text rendering: `Error: <message>`, then `Recovery: <hint>` when `data.recovery.hint` adds something the message does not already say, then `(reason <reason> · not retryable · request <id>)` for whichever of `data.reason` / `data.retryable` / `data.requestId` is present | Claude Desktop and other format()-only clients |
|
|
397
|
+
| `structuredContent.error` | JSON `{ code, message, data? }` carrying the error code, message, any structured data from the thrown `McpError` or `ZodError`, and `data.requestId` | Claude Code and other structuredContent-only clients |
|
|
398
|
+
|
|
399
|
+
```text
|
|
400
|
+
Error: No data for 3 PMIDs
|
|
401
|
+
|
|
402
|
+
Recovery: Use pubmed_search_articles to discover valid PMIDs.
|
|
403
|
+
|
|
404
|
+
(reason no_match · not retryable · request UTFAC-QE0MB)
|
|
405
|
+
```
|
|
410
406
|
|
|
411
407
|
Important properties:
|
|
412
408
|
- **`_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.
|
|
413
|
-
- **`data` propagation is restricted** to explicitly-thrown `McpError.data
|
|
409
|
+
- **`data` propagation is restricted** to explicitly-thrown `McpError.data`, `ZodError.issues`, and the request id. Auto-classified plain errors (`TypeError`, network errors, etc.) emit `code`, `message`, and `data: { requestId }` only, so internal classification context never leaks to clients.
|
|
410
|
+
- **`data.requestId` names the request.** The framework sets it on every error envelope it builds — a tool result (handler throws, argument rejections, auth refusals, output-contract failures), a failed resource read, a failed prompt, and the JSON-RPC errors `httpErrorHandler` returns — to the `requestId` that call's log records carry. On a tool, resource, or prompt call that is a generated `XXXXX-XXXXX` token, or the client's JSON-RPC id when that id is a string; `httpErrorHandler` generates its own token, the one on its `Client error:` record. A failure reported from the client resolves to its `Error in tool:<name>` record by that value. A resource read refused before it is measured (an auth refusal, or URI variables that fail `params`) carries an id no log record shares, since resources write no failure record of their own. It is added where the envelope is built, never to the thrown `McpError.data`, so `ErrorHandler.handleError` / `tryCatch` results and the log record's `errorData` stay context-free; it replaces a thrown `data.requestId`, the way canonical fields win in log records. Two envelopes go without it: a resource `-32602` whose `data` is exactly `{ uri }` (the resource-not-found shape clients match exactly), and `runToolContract` results, which have no real request. It closes the `content[]` terms line, alone as `(request <id>)` when there is no `reason` or `retryable` — so a test pinning `content[0].text` exactly sees it.
|
|
414
411
|
- **Recovery hint mirroring is automatic, unless the hint repeats the message.** 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. The one exception is a hint the trimmed message already contains verbatim (case-sensitively) — an argument rejection whose every hint sentence restates an issue, where the hint is the message's issue text verbatim, and an author hint that restates its own message. There the line adds no next step, so it is dropped from the text; `structuredContent.error.data.recovery.hint` stays populated either way.
|
|
415
|
-
- **`reason` and `
|
|
412
|
+
- **`reason`, `retryable`, and `requestId` render as a trailing term line.** `(reason malformed_id · not retryable · request UTFAC-QE0MB)` closes the text whenever `data.reason` is a non-empty string, `data.retryable` is a boolean, or `data.requestId` is a non-empty string — `retryable` for `true`, `not retryable` for `false`, in that order. None present (an `McpError` with no `data` built outside a request, as `runToolContract` does) appends nothing at all. The numeric `code` and `data.issues` stay JSON-only on purpose: the code is the one envelope field a model cannot act on, and the message already renders each issue as a sentence. A consumer test pinning `content[0].text` exactly, rather than asserting it contains the diagnostic, therefore moves for any error carrying a reason.
|
|
416
413
|
- **Argument-schema rejection is a tool error with the same envelope.** An unknown root key, a wrong type, a missing required field, or a failed constraint returns `isError: true` with `structuredContent.error.code = -32602` (`InvalidParams`) and the readable `Invalid arguments for tool <name>: …` diagnostic in `content[]`. The handler never runs. Two neighbouring failures keep the protocol error path instead, arriving as a JSON-RPC error rather than a tool result: an unknown or disabled tool name, and a malformed request envelope.
|
|
417
|
-
- **`invalid_arguments` is the framework-owned reason on every argument rejection.** The rejection carries `data.reason: "invalid_arguments"` and a `data.recovery.hint` the framework synthesizes from the Zod issues, the arguments as sent, and the root schema — an unknown root key names the root properties the tool does accept, an unknown key inside a nested strict object names its full path and that object's own keys (`Unknown key opts.b. opts accepts: a.`, `Unknown key items.1.b. items.1 accepts: name.`), a wrong type names the type to send instead (a fractional number on an integer field reads `Send rows as an integer, not a fractional number.`), missing fields collapse into one `Provide …` sentence, and anything else restates its diagnostic line, field path included (`start: Must be a parseable ISO 8601 date`), so identical constraints on different fields stay distinguishable. When every sentence restates an issue, the hint is the message's issue text verbatim, and its `Recovery:` line is dropped from `content[]`. Beside a framework sentence each restatement is terminated and a repeated sentence appears once: `start: Must be a parseable ISO 8601 date. Send n as a number, not a string.` When pre-validation rewrote or dropped a key the caller wrote, the rejection says so, since its issues name only the keys that were validated: `data.input` carries `{ aliased: [{ alias, target }], ignored: [...] }` — keys only, in argument order, `ignored` holding the undeclared underscore-prefixed keys the drop discarded — and the hint closes with `Validated query as targetQuery.` / `Dropped undeclared key _max.`, framework sentences that keep the `Recovery:` line. An ignore-list drop (`_meta`, `toolCallId`, a server's `input.ignoreKeys`) is a client artifact and is never reported, and a rejection the step changed nothing on carries no `data.input`. The reason renders
|
|
418
|
-
- **`client_capability_missing` is the other framework-owned reason.** When a handler returns `ctx.requestInput({ inputRequests: … })` on a 2025-era connection whose client declared no matching capability, `ctx.requestInput` throws this failure in place of the input-required signal, before anything reaches the wire. It is an ordinary handler throw from there on
|
|
419
|
-
- **A schema constraint cannot carry a *declared* reason.** Because the handler never runs, a rejection by `.max()`, `.regex()`, `.min()`, or any other Zod refinement bypasses `errors[]` entirely: it arrives as `InvalidParams` with `data.issues` under the framework's `invalid_arguments`, never the `reason` and authored `recovery` of a contract entry — so a caller has nothing tool-specific to branch on and gets only the schema-derived hint. Decide per constraint which surface it belongs on. A bound that is purely structural — the input is the wrong shape and no guidance beyond the diagnostic would help — belongs on the schema, where it also advertises itself in `inputSchema`. A bound a caller is expected to recover from belongs in the handler as `ctx.fail('reason', message
|
|
414
|
+
- **`invalid_arguments` is the framework-owned reason on every argument rejection.** The rejection carries `data.reason: "invalid_arguments"` and a `data.recovery.hint` the framework synthesizes from the Zod issues, the arguments as sent, and the root schema — an unknown root key names the root properties the tool does accept, an unknown key inside a nested strict object names its full path and that object's own keys (`Unknown key opts.b. opts accepts: a.`, `Unknown key items.1.b. items.1 accepts: name.`), a wrong type names the type to send instead (a fractional number on an integer field reads `Send rows as an integer, not a fractional number.`), missing fields collapse into one `Provide …` sentence, and anything else restates its diagnostic line, field path included (`start: Must be a parseable ISO 8601 date`), so identical constraints on different fields stay distinguishable. When every sentence restates an issue, the hint is the message's issue text verbatim, and its `Recovery:` line is dropped from `content[]`. Beside a framework sentence each restatement is terminated and a repeated sentence appears once: `start: Must be a parseable ISO 8601 date. Send n as a number, not a string.` When pre-validation rewrote or dropped a key the caller wrote, the rejection says so, since its issues name only the keys that were validated: `data.input` carries `{ aliased: [{ alias, target }], ignored: [...] }` — keys only, in argument order, `ignored` holding the undeclared underscore-prefixed keys the drop discarded — and the hint closes with `Validated query as targetQuery.` / `Dropped undeclared key _max.`, framework sentences that keep the `Recovery:` line. An ignore-list drop (`_meta`, `toolCallId`, a server's `input.ignoreKeys`) is a client artifact and is never reported, and a rejection the step changed nothing on carries no `data.input`. The reason renders on the closing `(reason invalid_arguments · request <id>)`; this path sets no `retryable`. Its `Error in tool:<name>` record logs at `notice`, not `error`. Authors declare nothing for this: the rejection happens before the handler and the hint is derived from the schema.
|
|
415
|
+
- **`client_capability_missing` is the other framework-owned reason.** When a handler returns `ctx.requestInput({ inputRequests: … })` on a 2025-era connection whose client declared no matching capability, `ctx.requestInput` throws this failure in place of the input-required signal, before anything reaches the wire. It is an ordinary handler throw from there on, logged at `notice`: measured as the failed call it is, and shaped by the family's usual error path — a tool gets `structuredContent.error.code = -32600` (`InvalidRequest`), `data.reason: "client_capability_missing"`, and a `data.recovery.hint` naming the capability; a resource read gets the same code, reason, and hint through the JSON-RPC error envelope. The hint ends at reconnecting with a client that declares the capability, since a consent gate has no argument that could stand in for its answer; a handler whose arguments can appends its own sentence per call with `ctx.requestInput(spec, { fallbackHint })`. Like `invalid_arguments`, a definition cannot declare it in `errors[]`: it names a property of the connection, not a domain outcome. See `api-context`'s `ctx.requestInput`.
|
|
416
|
+
- **A schema constraint cannot carry a *declared* reason.** Because the handler never runs, a rejection by `.max()`, `.regex()`, `.min()`, or any other Zod refinement bypasses `errors[]` entirely: it arrives as `InvalidParams` with `data.issues` under the framework's `invalid_arguments`, never the `reason` and authored `recovery` of a contract entry — so a caller has nothing tool-specific to branch on and gets only the schema-derived hint. Decide per constraint which surface it belongs on. A bound that is purely structural — the input is the wrong shape and no guidance beyond the diagnostic would help — belongs on the schema, where it also advertises itself in `inputSchema`. A bound a caller is expected to recover from belongs in the handler as `ctx.fail('reason', message)` against a declared `errors[]` entry, whose `recovery` the framework puts on the wire, with the limit restated in the field's `.describe()` so it is still visible before the call. Enforcing the same bound in both places is the trap: the schema wins, and the contract entry becomes unreachable while still reading as covered.
|
|
420
417
|
- **A rejected value never reaches the client.** The rendered sentence distinguishes an omitted field from a wrong one (`what: Missing required field. Expected one of "os"|"cpu"` rather than the invalid-option text), and a union renders the branch that says what would have been accepted instead of Zod's `Invalid input` placeholder. Both read the arguments in-process for the absent/present bit and the arriving type only — `data.issues` ships the Zod issues as-is, and no value the caller sent is copied onto them.
|
|
421
418
|
- **A union branch names its own field.** Each branch issue is prefixed with the path it names relative to that branch, so two alternatives differing only in which field they require stay distinguishable: `spec: kind: Invalid option: expected one of "x"|"y"; n: Invalid input: expected number, received undefined or other: Invalid input: expected string, received undefined`. Issues *within* one branch join on `; `, across branches on ` or `, and top-level issues on `, ` — three nestings, three separators. A scalar branch carries no path and renders as before. `data.issues` still ships the raw nested Zod issues, and `data.recovery.hint` restates the same line, field path included.
|
|
422
419
|
- **A one-or-many union renders like the field it wraps.** Once a union branch fails below its root, every branch whose only issue is a root type mismatch is dropped — for `z.union([z.array(Item), Item])` given a list, that is the object branch saying only that the value is an array. If one branch remains, its issues render and hint under the field's path exactly as they would on a non-union field: `items.1.name: Invalid input: expected string, received boolean`, hinted `Send items.1.name as a string, not a boolean.` A missing element field is hinted `Provide items.1.name.`, and the rule applies again at every nested level. When every branch fails at its root (`items: "x"`), all of them render, joined by ` or `. `data.issues` keeps Zod's single `invalid_union` issue.
|
|
@@ -470,7 +467,7 @@ const parsed = await ErrorHandler.tryCatch(
|
|
|
470
467
|
|
|
471
468
|
`tryCatch` always logs and rethrows — it never swallows errors. The `fn` argument may be synchronous or return a `Promise`; both are handled via `Promise.resolve(fn())`.
|
|
472
469
|
|
|
473
|
-
**The thrown error's `data` is wire-visible.** A handler that lets it propagate forwards it as `structuredContent.error.data` (tools) or JSON-RPC `error.data` (resources, prompts). It carries the caught `McpError`'s own `data`, `originalErrorName`, `originalMessage`, and `rootCause` (`{ name, message }`) — never a stack and never `context`: `originalStack`, the full `causeChain`, and every `context` field (`requestId`, `sessionId`, `traceId`, `tenantId`, `extra`, …) go to the log record only. A field the caller should act on belongs in the thrown `McpError`'s `data`, not in `context`.
|
|
470
|
+
**The thrown error's `data` is wire-visible.** A handler that lets it propagate forwards it as `structuredContent.error.data` (tools) or JSON-RPC `error.data` (resources, prompts). It carries the caught `McpError`'s own `data`, `originalErrorName`, `originalMessage`, and `rootCause` (`{ name, message }`) — never a stack and never `context`: `originalStack`, the full `causeChain`, and every `context` field (`requestId`, `sessionId`, `traceId`, `tenantId`, `extra`, …) go to the log record only. A field the caller should act on belongs in the thrown `McpError`'s `data`, not in `context`. (The handler factory adds the call's own `data.requestId` when it builds the envelope; that value never comes from `context` here.)
|
|
474
471
|
|
|
475
472
|
**Options** (`Omit<ErrorHandlerOptions, 'rethrow'>`):
|
|
476
473
|
|
|
@@ -497,7 +494,7 @@ const response = await fetch(url, { signal: ctx.signal });
|
|
|
497
494
|
if (!response.ok) {
|
|
498
495
|
throw await httpErrorFromResponse(response, {
|
|
499
496
|
service: 'NCBI', // included in message
|
|
500
|
-
data: { endpoint,
|
|
497
|
+
data: { endpoint }, // the framework adds data.requestId
|
|
501
498
|
});
|
|
502
499
|
}
|
|
503
500
|
```
|
|
@@ -570,7 +567,6 @@ The linter validates the structure of `errors[]` and (when present) cross-checks
|
|
|
570
567
|
| `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. |
|
|
571
568
|
| `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. |
|
|
572
569
|
| `error-contract-unthrown` | warning | A declared `reason` that no literal `ctx.fail('<reason>'` or `ctx.recoveryFor('<reason>'` in the handler names. Fires only when the handler already holds at least one literal `ctx.fail(`, and skips the definition entirely when either callee takes a non-literal first argument. Wire the throw, drop the entry, or mark it `thrownBy: 'service'`. |
|
|
573
|
-
| `error-contract-recovery-unforwarded` | warning | A literal `ctx.fail('<reason>', …)` site carrying neither `ctx.recoveryFor('<reason>')` nor its own `recovery` key, so the declared hint reaches neither client surface. One diagnostic per site; skips a site whose data argument the scan cannot read. |
|
|
574
570
|
|
|
575
571
|
### Baseline codes (auto-allowed)
|
|
576
572
|
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
MCP definition linter rules reference. Use when `bun run lint:mcp` or `bun run devcheck` reports a lint error or warning (`format-parity`, `schema-is-object`, `name-format`, `server-json-*`, etc.) and you need to understand the rule, its severity, and how to fix it. Every rule ID the linter emits has an entry in this doc.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.21"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -53,7 +53,7 @@ Grouped by family. Jump to any rule ID via its anchor.
|
|
|
53
53
|
| Prompts | `generate-required` | [Prompt rules](#prompt-rules) |
|
|
54
54
|
| Handler body | `prefer-mcp-error-in-handler`, `prefer-error-factory`, `preserve-cause-on-rethrow`, `no-stringify-upstream-error` | [Handler body rules](#handler-body-rules) |
|
|
55
55
|
| 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-severity-unknown`, `error-contract-recovery-required`, `error-contract-recovery-empty`, `error-contract-recovery-min-words` | [Error contract rules](#error-contract-rules) |
|
|
56
|
-
| Error contract (conformance) | `error-contract-conformance`, `error-contract-prefer-fail`, `error-contract-unthrown
|
|
56
|
+
| Error contract (conformance) | `error-contract-conformance`, `error-contract-prefer-fail`, `error-contract-unthrown` | [Error contract rules](#error-contract-rules) |
|
|
57
57
|
| Enrichment | `enrichment-type`, `enrichment-empty`, `enrichment-field-type`, `enrichment-output-collision`, `enrichment-prefer-block`, `enrichment-trailer-render`, `enrichment-trailer-orphan`, `enrichment-trailer-unknown-field`, `capped-list-no-truncation` | [Enrichment rules](#enrichment-rules) |
|
|
58
58
|
| server.json | ~40 rules prefixed `server-json-*` | [server.json rules](#server-json-rules) |
|
|
59
59
|
|
|
@@ -866,7 +866,7 @@ Fires when an entry's optional `severity` field is present but isn't one of `deb
|
|
|
866
866
|
|
|
867
867
|
**Severity:** error
|
|
868
868
|
|
|
869
|
-
Fires when an entry's `recovery` field is missing or not a string. `recovery` is the agent's next-move guidance when this failure fires — it
|
|
869
|
+
Fires when an entry's `recovery` field is missing or not a string. `recovery` is the agent's next-move guidance when this failure fires — the handler factory sends it as `data.recovery.hint` with any failure carrying the entry's reason and no hint of its own.
|
|
870
870
|
|
|
871
871
|
### error-contract-recovery-empty
|
|
872
872
|
|
|
@@ -948,37 +948,13 @@ async handler(input, ctx) {
|
|
|
948
948
|
}
|
|
949
949
|
```
|
|
950
950
|
|
|
951
|
-
The field is lint-only metadata: `ctx.fail`, `ctx.recoveryFor`, the `severity` lookup, and the advertised error envelope never read it, so a marked entry is typed, advertised, and thrown exactly as an unmarked one. Prefer it over the workarounds that also silence the rule — moving the literal `ctx.fail` into a module-level helper turns the whole tool off, handler-local reasons included.
|
|
951
|
+
The field is lint-only metadata: `ctx.fail`, `ctx.recoveryFor`, the recovery fill, the `severity` lookup, and the advertised error envelope never read it, so a marked entry is typed, advertised, and thrown exactly as an unmarked one. Prefer it over the workarounds that also silence the rule — moving the literal `ctx.fail` into a module-level helper turns the whole tool off, handler-local reasons included.
|
|
952
952
|
|
|
953
953
|
**Trigger.** Only when the handler holds at least one literal `ctx.fail(`. A handler with none produces its reasons somewhere the scan cannot reach, so firing there would warn on every service-layer definition. A `ctx.fail(` or `ctx.recoveryFor(` whose first argument is not a string literal — a variable, a template literal, a map lookup — makes the named set unknowable, and the whole definition is skipped rather than guessed at.
|
|
954
954
|
|
|
955
955
|
**Heuristic limitations:** the scan reads `handler.toString()` and matches call sites in the comment- and string-stripped text, so a `ctx.fail('…')` written inside a comment or nested in another literal does not count as thrown. A reason produced outside the handler closure is invisible to any `toString()` scan, which is why the rule can never prove absence and stays a warning. Still silent without a marker: a `createFail(errors)` resolver built outside the handler, and an aliased `const fail = ctx.fail`.
|
|
956
956
|
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
**Severity:** warning
|
|
960
|
-
|
|
961
|
-
Fires per literal `ctx.fail('<reason>', …)` site that does not put the contract's `recovery` on the wire.
|
|
962
|
-
|
|
963
|
-
`recovery` is required on every `errors[]` entry, but reaching the client with it is opt-in — the throw site forwards `ctx.recoveryFor('<reason>')`, or passes its own `recovery` key. A site that does neither ships `reason` and `retryable` with no hint, and since the framework mirrors `data.recovery.hint` into the error `content[]`, both client surfaces lose it together. Nothing else catches this: the contract is declared, `lint:mcp` passes, and an error-path test asserting `code` and `reason` passes with the hint absent.
|
|
964
|
-
|
|
965
|
-
**Fix:** forward the resolver at the site named in the diagnostic.
|
|
966
|
-
|
|
967
|
-
```ts
|
|
968
|
-
// warns
|
|
969
|
-
throw ctx.fail('rate_limited', 'Upstream rate limit exceeded');
|
|
970
|
-
|
|
971
|
-
// clean — any of
|
|
972
|
-
throw ctx.fail('rate_limited', msg, { ...ctx.recoveryFor('rate_limited') });
|
|
973
|
-
throw ctx.fail('rate_limited', msg, ctx.recoveryFor('rate_limited'));
|
|
974
|
-
throw ctx.fail('rate_limited', msg, { recovery: { hint: `Retry in ${waitSeconds}s.` } });
|
|
975
|
-
```
|
|
976
|
-
|
|
977
|
-
**Per site, not per reason.** A handler wiring one of six throws is covered at one of them, so each site is judged on its own argument list. Two sites naming one reason, one forwarding and one bare, produce exactly one diagnostic. A site whose only resolver names a *different* reason warns too, naming both — the caller would otherwise get another failure mode's guidance.
|
|
978
|
-
|
|
979
|
-
**Bails.** A non-literal first argument on either `ctx.fail(` or `ctx.recoveryFor(` skips the whole definition, as it does for `error-contract-unthrown`. A resolver sitting outside every fail span — a hoisted `const hint = ctx.recoveryFor('x')` — skips that reason, since the binding is assembled where the scan cannot follow it. A data argument the scan cannot read skips that one site: an identifier (`ctx.fail('r', msg, data)`), a call other than the resolver, or an object literal spreading another value (`{ ...details }`), any of which may carry `recovery` already. An object literal of plain keys carrying no `recovery` still warns.
|
|
980
|
-
|
|
981
|
-
**Heuristic limitations:** same `handler.toString()` scan as `error-contract-unthrown`, so a call written inside a comment or nested in another literal is not a site, and a failure thrown below the handler is invisible. The rule speaks only for the sites it sees, which is why it stays a warning.
|
|
957
|
+
No rule checks that a throw site forwards the declared `recovery`: the handler factory fills `data.recovery.hint` from the entry for any failure carrying its reason and no hint of its own, so a bare `ctx.fail('<reason>')` and a service throw both reach the client with it. See `api-errors`.
|
|
982
958
|
|
|
983
959
|
---
|
|
984
960
|
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Stand up a persistent, self-refreshing local mirror of a bulk upstream dataset with the MirrorService (@cyanheads/mcp-ts-core/mirror). Use when a server wraps a large or slow API and should query a synced local index (embedded SQLite + FTS5) instead of paginating the live API per request.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.4"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -65,7 +65,7 @@ Why they can't merge: during a from-scratch init the records aren't ordered by t
|
|
|
65
65
|
|
|
66
66
|
| Framework | Server |
|
|
67
67
|
|---|---|
|
|
68
|
-
| Cross-runtime SQLite handle, WAL + `busy_timeout` | The `sync` generator (the ingester) |
|
|
68
|
+
| Cross-runtime SQLite handle, WAL + `busy_timeout`; an open waits out another connection's lock for `busyTimeoutMs` | The `sync` generator (the ingester) |
|
|
69
69
|
| `mirror_sync_state` + cursor/checkpoint state machine | Translating your query syntax → FTS5 `match` |
|
|
70
70
|
| `runSync({ init \| refresh })`, per-page persist, resume | Mapping upstream records → row objects |
|
|
71
71
|
| Schema gen (columns + FTS + tokenizer + triggers) | Migration *content* (the `up` functions) |
|
|
@@ -93,6 +93,7 @@ A migration runs identically on first creation and on upgrade: a fresh database
|
|
|
93
93
|
The service owns `runSync` + state; it does not schedule. Wire "self-refreshing" yourself:
|
|
94
94
|
|
|
95
95
|
- **Refresh** — register `runSync({ mode: 'refresh' })` on a cron via `schedulerService` from `@cyanheads/mcp-ts-core/utils`, inside `setup()`. Gate on transport (HTTP) when stdio operators run it out-of-band.
|
|
96
|
+
- **Off the serving thread** — SQLite calls are synchronous, so each write transaction a sync runs blocks the event loop until it commits. A refresh whose pages or post-sync recomputes take seconds stalls every request, `/healthz` included, for that long. Run such a scheduled sync in a child process instead: spawn `process.execPath` with `process.execArgv` and a compiled job entry in `dist/`, relay its log lines, and in `teardown` stop it with SIGTERM, then SIGKILL after a grace period. In-process scheduling suits mirrors whose transactions stay short.
|
|
96
97
|
- **Init** — run out-of-band (a CLI script / one-shot), never on startup: a full init can take hours and must not block the server. It is idempotent and resumable — re-running after an interrupt continues from the persisted cursor.
|
|
97
98
|
|
|
98
99
|
### Shipping the mirror CLI in a production Docker image
|
|
@@ -122,7 +123,7 @@ RUN echo '{"compilerOptions":{"baseUrl":".","paths":{"@/*":["./dist/*"]}}}' > ts
|
|
|
122
123
|
|
|
123
124
|
**Caveat:** this relies on Bun's runtime `paths` resolution. A Node runtime image (no native `.ts` execution) needs the scripts compiled into `dist/` instead — a separate tsconfig pass with a different `rootDir` is required in that case.
|
|
124
125
|
|
|
125
|
-
**`package.json` `files[]`:** add `scripts/_mirror-context.ts` and the three named lifecycle scripts so the npm tarball and `.mcpb` bundle carry them.
|
|
126
|
+
**`package.json` `files[]`:** add `scripts/_mirror-context.ts` and the three named lifecycle scripts so the npm tarball and `.mcpb` bundle carry them. They resolve `@/` only where a `tsconfig.json` maps it (a dev checkout, or the image above). An npm install ships no such mapping, so an npm-installed server cannot run them: document building the mirror from a checkout or the image.
|
|
126
127
|
|
|
127
128
|
## Checklist
|
|
128
129
|
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Catalog of OpenTelemetry instrumentation built into framework `@cyanheads/mcp-ts-core` — spans, metrics, completion logs, env config, runtime caveats, custom instrumentation patterns, and cardinality rules. Use when enabling OTel export, adding custom spans or metrics in services, debugging missing telemetry, looking up attribute names, or deciding what's safe to put on a metric attribute vs. a span.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.17"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -94,7 +94,7 @@ Every handler call gets a span. Nested operations (storage, graph, LLM) become c
|
|
|
94
94
|
| Span name | Source | Key attributes |
|
|
95
95
|
|:----------|:-------|:---------------|
|
|
96
96
|
| `tool_execution:<tool>` | every tool call | `mcp.tool.input_bytes`, `mcp.tool.output_bytes`, `mcp.tool.duration_ms`, `mcp.tool.success`, `mcp.tool.error_code`, `mcp.tool.input_required`, `mcp.tool.partial_success`, `mcp.tool.batch.{succeeded,failed}_count` |
|
|
97
|
-
| `resource_read:<resource>` | every resource handler | `mcp.resource.uri
|
|
97
|
+
| `resource_read:<resource>` | every resource handler | `mcp.resource.uri` (userinfo, query, and fragment stripped, then cut to its first 1,024 characters), `mcp.resource.uri_length` (the uncut length, only when the cut removed something), `mcp.resource.mime_type`, `mcp.resource.size_bytes`, `mcp.resource.duration_ms`, `mcp.resource.success`, `mcp.resource.error_code`, `mcp.resource.input_required` |
|
|
98
98
|
| `prompt_generation:<prompt>` | every prompt handler | `mcp.prompt.input_bytes`, `mcp.prompt.output_bytes`, `mcp.prompt.message_count`, `mcp.prompt.duration_ms`, `mcp.prompt.success`, `mcp.prompt.error_code`, `mcp.prompt.input_required` |
|
|
99
99
|
| `storage:<op>` | `StorageService` (every call) | `mcp.storage.operation`, `mcp.storage.duration_ms`, `mcp.storage.success`, `mcp.storage.key_count` (batch ops) |
|
|
100
100
|
| `graph:<op>` | `GraphService` (every call) | `mcp.graph.operation`, `mcp.graph.duration_ms`, `mcp.graph.success` |
|
|
@@ -214,15 +214,17 @@ A dashboard reading `error_category` alone therefore no longer needs to special-
|
|
|
214
214
|
A definition may put `severity` on an `errors[]` entry — `debug`, `info`, `notice`, or `warning` — for an outcome it models rather than suffers. Two things move, and nothing else:
|
|
215
215
|
|
|
216
216
|
- The `Error in tool:<name>` log record is emitted at that level instead of `error`, with the same message and structured fields.
|
|
217
|
-
- `mcp.errors.classified` gains `mcp.error.severity` on that record. It is set only when a
|
|
217
|
+
- `mcp.errors.classified` gains `mcp.error.severity` on that record. It is set only when a severity resolved below `error`.
|
|
218
218
|
|
|
219
|
-
The
|
|
219
|
+
The framework's own refusals resolve one without a declaration: an argument rejection (`invalid_arguments`) and a `ctx.requestInput` the client connection cannot serve (`client_capability_missing`) log at `notice`, so even a server that declares no severity sees `mcp.error.severity: "notice"` on those `mcp.errors.classified` increments — a bounded split a dashboard can use to separate caller rejections from faults. An argument rejection opens no execution span and reaches no call counter either way; it still counts once on `mcp.tool.rejections`.
|
|
220
|
+
|
|
221
|
+
The call still failed: the execution span keeps `SpanStatusCode.ERROR` and its recorded exception, `mcp.tool.calls` / `mcp.tool.duration` / `mcp.tool.errors` record the same values, and the completion log still reads `isSuccess: false`. Splitting those series on an authoring decision would redefine what an error rate means. Tools only — resources write no failure record of their own. A cancelled request keeps its own `info`, stack-free path whatever the contract declares. See `api-errors`.
|
|
220
222
|
|
|
221
223
|
### Errors, rate limits, HTTP client
|
|
222
224
|
|
|
223
225
|
| Metric | Type | Unit | Attributes |
|
|
224
226
|
|:-------|:-----|:-----|:-----------|
|
|
225
|
-
| `mcp.errors.classified` | counter | `{errors}` | `mcp.error.classified_code` (JSON-RPC code), `mcp.error.category` (`upstream`/`server`/`client`, as in [Error category](#error-category)), `operation`, and `mcp.error.severity` when
|
|
227
|
+
| `mcp.errors.classified` | counter | `{errors}` | `mcp.error.classified_code` (JSON-RPC code), `mcp.error.category` (`upstream`/`server`/`client`, as in [Error category](#error-category)), `operation`, and `mcp.error.severity` when a tool failure's level resolved below `error` — a declared severity, or `notice` for an `invalid_arguments` / `client_capability_missing` refusal |
|
|
226
228
|
| `mcp.ratelimit.rejections` | counter | `{rejections}` | — (the rate-limit key is caller-supplied and typically per-client, so it would materialize an unbounded series in the meter; per-key attribution lives on the span instead) |
|
|
227
229
|
| `http.client.request.duration` | histogram | `s` | `http.request.method`, `server.address`, `http.response.status_code` (when > 0; absent on network errors before a response is received) |
|
|
228
230
|
|
|
@@ -245,17 +247,21 @@ Auto-registered when `process.memoryUsage` / `process.uptime` / `perf_hooks` are
|
|
|
245
247
|
|
|
246
248
|
Every framework log record carries `requestId`, `traceId`, `spanId`, and `tenantId` from the request context, so every log line is searchable by trace. `@opentelemetry/instrumentation-pino` does not touch these records: it patches only a `pino` loaded after the SDK starts. To ship the records to the same backend as traces, set `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` (see Enabling export).
|
|
247
249
|
|
|
248
|
-
For domain logging inside handlers, use `ctx.log` (`debug`/`info`/`notice`/`warning`/`error`) — auto-includes `requestId`, `traceId`, `tenantId`, `spanId`. The completion log emitted at the end of every handler carries a `metrics` payload, with fields tuned to each surface:
|
|
250
|
+
For domain logging inside handlers, use `ctx.log` (`debug`/`info`/`notice`/`warning`/`error`) — auto-includes `requestId`, `traceId`, `tenantId`, `spanId`. The completion log emitted at the end of every handler — at `info`, whatever the outcome — carries a `metrics` payload, with fields tuned to each surface:
|
|
249
251
|
|
|
250
252
|
| Handler | Log message | `metrics` fields |
|
|
251
253
|
|:--------|:------------|:-----------------|
|
|
252
254
|
| Tool | `Tool execution finished.` | `durationMs`, `isSuccess`, `errorCode`, `inputBytes`, `outputBytes`, plus `partialSuccess` / `batchSucceeded` / `batchFailed` when the result is a partial-success batch |
|
|
253
|
-
| Resource | `Resource read finished.` | `durationMs`, `isSuccess`, `errorCode`, `outputBytes`, `uri
|
|
254
|
-
| Prompt | `Prompt generation finished.`
|
|
255
|
+
| Resource | `Resource read finished.` | `durationMs`, `isSuccess`, `errorCode`, `outputBytes`, `uri` (the same capped URI as `mcp.resource.uri`), `mimeType` |
|
|
256
|
+
| Prompt | `Prompt generation finished.` | `durationMs`, `isSuccess`, `errorCode`, `inputBytes` (0 for a prompt declaring no arguments), `outputBytes`, `messageCount` |
|
|
257
|
+
|
|
258
|
+
Every record of a resource read — scope checks, the handler's `ctx.log` lines, the completion record — carries the read's URI as `resourceUri`, capped like `mcp.resource.uri`, plus `resourceUriLength` when the cap cut it. The handler's `ctx.uri` and the response keep the full URI.
|
|
259
|
+
|
|
260
|
+
A failed tool call or prompt adds exactly one `Error in tool:<name>` / `Error in prompt:<name>` record. Each call — prompts included — logs under its own `requestId`, and the client receives that value as `data.requestId` on the call's error envelope, so a reported failure resolves to its records.
|
|
255
261
|
|
|
256
262
|
### Failed-call payloads
|
|
257
263
|
|
|
258
|
-
Off by default. With `LOG_TOOL_FAILURE_PAYLOADS=true`, a failed tool call writes one more record right after its `Error in tool:<name>` record: message `Tool failure payload: <name>`, the same request context (`requestId`, `traceId`, `spanId`, `toolName`), and the same level, a declared `severity` included.
|
|
264
|
+
Off by default. With `LOG_TOOL_FAILURE_PAYLOADS=true`, a failed tool call writes one more record right after its `Error in tool:<name>` record: message `Tool failure payload: <name>`, the same request context (`requestId`, `traceId`, `spanId`, `toolName`), and the same level, a declared `severity` and the `notice` of an argument rejection included. A payload record below `MCP_LOG_LEVEL` is dropped with its `Error in tool:` record, so at `warning` or above an argument rejection writes neither.
|
|
259
265
|
|
|
260
266
|
| Field | Content |
|
|
261
267
|
|:------|:--------|
|
|
@@ -312,7 +318,7 @@ Series are cheap to emit but expensive to store and query. The framework deliber
|
|
|
312
318
|
|
|
313
319
|
| On metrics | On spans / logs only |
|
|
314
320
|
|:-----------|:---------------------|
|
|
315
|
-
| `mcp.resource.name` (URI template) | `mcp.resource.uri` (
|
|
321
|
+
| `mcp.resource.name` (URI template) | `mcp.resource.uri` (URI with IDs, capped at 1,024 characters), `mcp.resource.uri_length` |
|
|
316
322
|
| `gen_ai.request.model` (bounded enum) | `mcp.tenant.id`, `mcp.client.id`, `mcp.auth.subject` |
|
|
317
323
|
| Bounded enum / template strings | Per-request unique IDs, free-form user input, opaque tokens |
|
|
318
324
|
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Testing patterns for MCP tool/resource handlers using `createMockContext` and Vitest. Covers mock context options, handler testing, McpError assertions, format testing, Vitest config setup, and test isolation conventions.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.14"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -15,6 +15,8 @@ Tests target handler behavior directly — call `handler(input, ctx)`, assert on
|
|
|
15
15
|
|
|
16
16
|
**Additional exports from `/testing`:** `createMockSession()` binds a mock handler context to an HTTP session; `createFetchMock()` provides a strict upstream HTTP fake; `runToolContract()` executes a definition through schema, handler, formatting, enrichment/content, and production-shaped error-envelope checks. `createMockLogger()` returns a standalone `MockContextLogger`, `createInMemoryStorage(options?)` provides a real `StorageService` backed by `InMemoryProvider`, and `expectInputRequired(run)` returns the `input_required` result a multi-round-trip handler asked for (see [Mock inputs](#mock-inputs)).
|
|
17
17
|
|
|
18
|
+
**Other testing subpaths:** `/testing/vitest` (fixtures and conformance suites, below), `/testing/fuzz` (see [Fuzz testing](#fuzz-testing)), and `/testing/apps`, whose `renderAppTool` renders an app tool's `ui://` view against your server (a stdio command or an HTTP URL) in a headless MCP Apps host and reports initialization, errors, CSP violations, the view↔host messages, and screenshots. It needs the optional peers `@modelcontextprotocol/client` and `@modelcontextprotocol/ext-apps` plus a `chrome-headless-shell` build; the `field-test` skill covers installing one and reading the report.
|
|
19
|
+
|
|
18
20
|
**Philosophy:** Test behavior, not implementation. Refactors should not break tests. Match the repo's existing test layout: fresh scaffolds use `tests/`, while colocated `src/**/*.test.ts` files are also supported. Integration tests at I/O boundaries over unit tests of internals.
|
|
19
21
|
|
|
20
22
|
---
|
|
@@ -133,10 +135,14 @@ toolContractSuite(searchTool, {
|
|
|
133
135
|
|
|
134
136
|
Use `runToolContract(definition, input, { context })` from `/testing` when a custom test runner or an imperative assertion is a better fit. It intentionally skips transport auth and telemetry; those belong in transport/integration tests.
|
|
135
137
|
|
|
138
|
+
A declared reason thrown without a hint — a bare `ctx.fail('reason')` or a service throw carrying `{ reason }` — comes back with the entry's `recovery` as `data.recovery.hint` and a `Recovery:` line in `content[]`, as in production. The one production field it leaves out is `data.requestId` (and the `request <id>` term closing `content[]`), since there is no real request; a test asserting the factory's envelope instead expects both. Calling `definition.handler(...)` directly returns the `McpError` exactly as the throw site built it — no fill, no request id.
|
|
139
|
+
|
|
136
140
|
Arguments that fail the `input` schema are rejected the way the production handler factory rejects them: `InvalidParams` (`-32602`), with a message naming the tool and every failing field. That is the code a client sees on the wire, so assert it — not `ValidationError` (`-32007`), which stays the classification for a `ZodError` a handler throws itself. A result that breaks the tool's own `output` or `enrichment` schema is the definition's bug, so it returns `InternalError` (`-32603`) with a message naming that contract, exactly as in production.
|
|
137
141
|
|
|
138
142
|
Cancellation settles as it does in production. Pass `context: { signal }` and abort it: once the signal has fired, whatever the handler — or the output validation, `format()`, and enrichment after it — throws comes back as `RequestCancelled` (`-32011`), whether that is the signal's `AbortError`, its reason string, a `withRetry` backoff that stopped, or an `McpError` of the handler's own. A throw while the signal is still live keeps its own classification, and argument parsing stays outside the settle, so schema-invalid arguments on an aborted signal still return `InvalidParams`. A `toolContractSuite` error case with an aborted `context.signal` asserts `code: JsonRpcErrorCode.RequestCancelled` the same way.
|
|
139
143
|
|
|
144
|
+
**Resources have no contract runner.** A resource definition's `handler(...)` called directly returns the throw site's `McpError` with no recovery fill, so asserting a resource's declared `errors[]` hints needs a path through the resource factory. Serve the definition from `createWorkerHandler({ name, title, resources: [def] })` (`/worker`) and `fetch` it a `resources/read` JSON-RPC request carrying the current protocol revision in both the `MCP-Protocol-Version` header and `params._meta`. The response is plain JSON or an SSE `data:` frame, and its `error` carries `code`, `data.reason`, and the filled `data.recovery.hint`.
|
|
145
|
+
|
|
140
146
|
---
|
|
141
147
|
|
|
142
148
|
## `createMockContext` options
|
|
@@ -149,6 +155,7 @@ createMockContext({ tenantId: 'test-tenant' }) // explicit tenant
|
|
|
149
155
|
createMockContext({ errors: myTool.errors }) // attaches typed ctx.fail keyed by the contract reasons
|
|
150
156
|
createMockContext({ inputResponses: { confirm: { action: 'accept', content: { ok: true } } } }) // second round of a multi-round-trip handler
|
|
151
157
|
createMockContext({ requestState: 'opaque-state' }) // seeds ctx.inputs.state()
|
|
158
|
+
createMockContext({ clientCapabilities: { roots: {} } }) // seeds ctx.clientCapabilities and filters inputResponses to declared kinds
|
|
152
159
|
createMockContext({ requestId: 'my-id' }) // override request ID (default: 'test-request-id')
|
|
153
160
|
createMockContext({ notifyResourceListChanged: () => {} }) // with resource-list change notifier
|
|
154
161
|
createMockContext({ notifyResourceUpdated: (_uri) => {} }) // with resource update notifier
|
|
@@ -162,6 +169,7 @@ createMockContext({ uri: new URL('myscheme://item/123') }) // for resource han
|
|
|
162
169
|
```ts
|
|
163
170
|
interface MockContextOptions<TErrors extends readonly ErrorContract[] | undefined> {
|
|
164
171
|
auth?: AuthContext;
|
|
172
|
+
clientCapabilities?: ClientCapabilities;
|
|
165
173
|
errors?: TErrors | undefined;
|
|
166
174
|
inputResponses?: InputResponses | Record<string, unknown>;
|
|
167
175
|
notifyPromptListChanged?: () => void;
|
|
@@ -181,6 +189,7 @@ interface MockContextOptions<TErrors extends readonly ErrorContract[] | undefine
|
|
|
181
189
|
|:-------|:-------|
|
|
182
190
|
| _(none)_ | Working `ctx.state` on tenant `'default'`; `ctx.inputs` is empty (first round) |
|
|
183
191
|
| `auth` | Sets `ctx.auth` for scope-checking tests |
|
|
192
|
+
| `clientCapabilities` | Sets `ctx.clientCapabilities` (`undefined` when omitted) and applies the production filter to `inputResponses`: only the answers these capabilities cover reach `ctx.inputs` (elicit → `elicitation`, and `elicitation.form` when it carries `content`; sampling → `sampling`, and `sampling.tools` when it holds a `tool_use` / `tool_result` block; roots → `roots`). Omitted, every seeded response reaches `ctx.inputs`, so existing `{ inputResponses }` tests are unaffected. The mock's `ctx.requestInput` stays ungated either way |
|
|
184
193
|
| `errors` | Attaches a typed `ctx.fail` against the contract — same wiring the production handler factory uses. Pass `myTool.errors` directly; the return type narrows to `HandlerContext<ReasonOf<…>>`, so the context is assignable to that definition's handler parameter. |
|
|
185
194
|
| `inputResponses` | Seeds `ctx.inputs` with the responses a retried request would carry, keyed by the identifiers the handler's `ctx.requestInput(...)` assigned (see below) |
|
|
186
195
|
| `notifyPromptListChanged` | Assigns `ctx.notifyPromptListChanged` for prompt-list change notification tests |
|
|
@@ -219,14 +228,14 @@ Reach for `createInMemoryStorage()` when a service takes a `StorageService` dire
|
|
|
219
228
|
|
|
220
229
|
### Mock inputs
|
|
221
230
|
|
|
222
|
-
`ctx.requestInput` is the real implementation: it throws an `InputRequiredSignal` the production handler factories convert into an `input_required` result. In a unit test the handler is called directly, so that signal surfaces as a thrown value — which is exactly how you assert the first round.
|
|
231
|
+
`ctx.requestInput` is the real implementation: it throws an `InputRequiredSignal` the production handler factories convert into an `input_required` result. In a unit test the handler is called directly, so that signal surfaces as a thrown value — which is exactly how you assert the first round. The examples below drive `export_report` from `api-context` § *The shape of a multi-round-trip handler*, which asks for a format the caller left out:
|
|
223
232
|
|
|
224
233
|
```ts
|
|
225
234
|
import { isInputRequiredSignal } from '@cyanheads/mcp-ts-core';
|
|
226
235
|
|
|
227
|
-
it('asks for
|
|
236
|
+
it('asks for the format on the first round', async () => {
|
|
228
237
|
const ctx = createMockContext();
|
|
229
|
-
await expect(
|
|
238
|
+
await expect(exportReport.handler(exportReport.input.parse({ reportId: 'r1' }), ctx))
|
|
230
239
|
.rejects.toSatisfy(isInputRequiredSignal);
|
|
231
240
|
});
|
|
232
241
|
```
|
|
@@ -236,30 +245,57 @@ To assert on *what* was requested, use `expectInputRequired` from `/testing`. It
|
|
|
236
245
|
```ts
|
|
237
246
|
import { createMockContext, expectInputRequired } from '@cyanheads/mcp-ts-core/testing';
|
|
238
247
|
|
|
239
|
-
const asked = await expectInputRequired(() =>
|
|
240
|
-
expect(asked.inputRequests?.
|
|
248
|
+
const asked = await expectInputRequired(() => exportReport.handler(input, createMockContext()));
|
|
249
|
+
expect(asked.inputRequests?.format?.method).toBe('elicitation/create');
|
|
241
250
|
```
|
|
242
251
|
|
|
243
252
|
Pass `asked.requestState` back as `createMockContext({ requestState })` when the handler reads state from the prior round. `inputResponses` drives the second round. `ctx.inputs.accepted(key, schema)` and `.view(key)` read it with the same helpers production uses, so a wrong response shape fails in the test:
|
|
244
253
|
|
|
245
254
|
```ts
|
|
246
|
-
it('
|
|
255
|
+
it('exports in the format the user picked', async () => {
|
|
247
256
|
const ctx = createMockContext({
|
|
248
|
-
inputResponses: {
|
|
257
|
+
inputResponses: { format: { action: 'accept', content: { format: 'csv' } } },
|
|
249
258
|
});
|
|
250
|
-
await expect(
|
|
259
|
+
await expect(exportReport.handler(input, ctx)).resolves.toMatchObject({ url: expect.any(String) });
|
|
251
260
|
});
|
|
252
261
|
|
|
253
262
|
it('stops when the user declines', async () => {
|
|
254
263
|
const ctx = createMockContext({
|
|
255
|
-
inputResponses: {
|
|
264
|
+
inputResponses: { format: { action: 'decline' } },
|
|
256
265
|
});
|
|
257
|
-
await expect(
|
|
266
|
+
await expect(exportReport.handler(input, ctx)).rejects.toThrow(McpError);
|
|
258
267
|
});
|
|
259
268
|
```
|
|
260
269
|
|
|
270
|
+
Seeding an answer this way is right for a handler that treats it as input. A consent gate does not: it acts only on a record it stored when it asked, so an answer seeded alone makes it ask again — see below.
|
|
271
|
+
|
|
261
272
|
`ctx.inputs.dropped` is always `[]` on a mock context — the drop only happens in the SDK's wire decoding, so cover it in an integration test rather than a unit one.
|
|
262
273
|
|
|
274
|
+
Seed `clientCapabilities` to test what a client without a capability gets: a pre-answered `inputResponses` the declared capabilities do not cover — a kind never declared, a form answer (one carrying `content`) from a client that declared only `elicitation.url`, a tool-use sampling answer without `sampling.tools` — never reaches `ctx.inputs`, so the handler asks again. A handler that falls through when a capability is missing (`if (ctx.clientCapabilities?.roots) … else …`) is tested by seeding both shapes.
|
|
275
|
+
|
|
276
|
+
A consent gate that redeems a `ctx.state` record (see `api-context` § *Consent gates*) needs the record in the second round's storage, and each mock context has its own. Copy what round one stored into the round-two context. The record carries the caller, so seed the same `auth` (or none) on both:
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
const accept = { confirm: { action: 'accept', content: { confirm: true } } };
|
|
280
|
+
|
|
281
|
+
const first = createMockContext();
|
|
282
|
+
const asked = await expectInputRequired(() => deletePath.handler(input, first));
|
|
283
|
+
const record = await first.state.get(`consent/${asked.requestState}`);
|
|
284
|
+
|
|
285
|
+
const second = createMockContext({ inputResponses: accept, requestState: asked.requestState });
|
|
286
|
+
await second.state.set(`consent/${asked.requestState}`, record);
|
|
287
|
+
await expect(deletePath.handler(input, second)).resolves.toEqual({ deleted: input.path });
|
|
288
|
+
|
|
289
|
+
// The record is spent: the same state again asks for a fresh confirmation.
|
|
290
|
+
await expect(deletePath.handler(input, second)).rejects.toSatisfy(isInputRequiredSignal);
|
|
291
|
+
|
|
292
|
+
// An answer with no record behind it asks as well — nothing was asked, so nothing was confirmed.
|
|
293
|
+
const unasked = createMockContext({ inputResponses: accept });
|
|
294
|
+
await expect(deletePath.handler(input, unasked)).rejects.toSatisfy(isInputRequiredSignal);
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
A record the round-two context holds under another `auth`, or one written by another tool (its `operation` differs), asks again the same way — cover whichever of those the handler's binding is meant to catch.
|
|
298
|
+
|
|
263
299
|
### Mock logger
|
|
264
300
|
|
|
265
301
|
`ctx.log` captures all log calls for inspection. Import `MockContextLogger` from `@cyanheads/mcp-ts-core/testing` and cast `ctx.log` to access the `.calls` array (the cast is necessary because `createMockContext` returns `Context`, which types `log` as `ContextLogger`):
|