@cyanheads/mcp-ts-core 0.13.5 → 0.13.6
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 +4 -4
- package/CLAUDE.md +4 -4
- package/README.md +1 -1
- package/biome.json +1 -1
- package/changelog/0.13.x/0.13.6.md +49 -0
- package/dist/linter/rules/enrichment-rules.d.ts +5 -4
- package/dist/linter/rules/enrichment-rules.d.ts.map +1 -1
- package/dist/linter/rules/enrichment-rules.js +99 -22
- package/dist/linter/rules/enrichment-rules.js.map +1 -1
- package/dist/linter/rules/error-contract-rules.d.ts +46 -10
- package/dist/linter/rules/error-contract-rules.d.ts.map +1 -1
- package/dist/linter/rules/error-contract-rules.js +180 -27
- package/dist/linter/rules/error-contract-rules.js.map +1 -1
- package/dist/linter/rules/format-parity-rules.js +1 -1
- package/dist/linter/rules/format-parity-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 +2 -1
- 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 +2 -1
- package/dist/linter/rules/tool-rules.js.map +1 -1
- package/dist/types-global/errors.d.ts +18 -0
- package/dist/types-global/errors.d.ts.map +1 -1
- package/framework-skills/add-service/SKILL.md +5 -2
- package/framework-skills/add-tool/SKILL.md +4 -2
- package/framework-skills/api-errors/SKILL.md +19 -3
- package/framework-skills/api-linter/SKILL.md +88 -15
- package/package.json +3 -3
- package/scripts/lint-mcp.ts +43 -4
- package/templates/AGENTS.md +1 -1
- package/templates/CLAUDE.md +1 -1
- package/templates/devcheck.config.json +1 -0
- package/templates/package.json +1 -1
- package/templates/src/mcp-server/tools/definitions/echo.tool.ts +7 -1
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Scaffold a new service integration. Use when the user asks to add a service, integrate an external API, or create a reusable domain module with its own initialization and state.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.11"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -234,10 +234,13 @@ Services don't declare `errors: [...]` contracts and don't have `ctx.fail` — t
|
|
|
234
234
|
- **Carry contract `reason` via `data: { reason }`** when the calling tool declares an `errors[]` contract entry for this failure mode. Services can't call `ctx.fail`, but passing the reason in `data` flows through the auto-classifier untouched, so clients see the same `error.data.reason` they'd see from `ctx.fail` — no handler-side catch-and-rethrow needed:
|
|
235
235
|
|
|
236
236
|
```ts
|
|
237
|
-
// tool declares: errors: [{ reason: 'empty_expression', code: JsonRpcErrorCode.ValidationError,
|
|
237
|
+
// tool declares: errors: [{ reason: 'empty_expression', code: JsonRpcErrorCode.ValidationError,
|
|
238
|
+
// when: '…', recovery: '…', thrownBy: 'service' }]
|
|
238
239
|
throw validationError('Expression cannot be empty.', { reason: 'empty_expression' });
|
|
239
240
|
```
|
|
240
241
|
|
|
242
|
+
The tool's entry carries `thrownBy: 'service'` so `error-contract-unthrown` — which reads the handler body and cannot see this throw — skips it while still checking whatever the handler throws itself. Lint-only metadata; nothing at runtime reads it.
|
|
243
|
+
|
|
241
244
|
- **Resolve contract `recovery` via `ctx.recoveryFor`** to land the contract's recovery hint on the wire without duplicating the string. Always-present on `Context`, returns `{}` when the calling tool has no matching reason — spread-safe regardless:
|
|
242
245
|
|
|
243
246
|
```ts
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Scaffold a new MCP tool definition. Use when the user asks to add a tool, create a new tool, or implement a new capability for the server.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "2.
|
|
7
|
+
version: "2.29"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -719,6 +719,8 @@ export const fetchArticles = tool('fetch_articles', {
|
|
|
719
719
|
|
|
720
720
|
`ctx.recoveryFor` returns `{}` when the calling tool has no contract or the reason isn't declared, so the spread is always safe — services don't have to know which tool called them.
|
|
721
721
|
|
|
722
|
+
Add `thrownBy: 'service'` to a contract entry the service produces once the handler also throws one of its own. `error-contract-unthrown` reads the handler body alone: as soon as one literal `ctx.fail(` appears there, every declared reason the body does not name is flagged, and the marker is what tells the rule this one is thrown a layer down. Lint-only metadata — the entry stays typed, advertised, and thrown exactly as an unmarked one.
|
|
723
|
+
|
|
722
724
|
See `add-service` for the full pattern.
|
|
723
725
|
|
|
724
726
|
#### Ad-hoc factory throws (fallback)
|
|
@@ -860,7 +862,7 @@ return { items: hits };
|
|
|
860
862
|
- [ ] Optional nested objects guarded for empty inner values from form-based clients (check `?.field` truthiness, not just object presence)
|
|
861
863
|
- [ ] No `console` calls — use `ctx.log` for handler logging
|
|
862
864
|
- [ ] `handler(input, ctx)` is pure — throws on failure, no try/catch (exception: batch tools with per-item isolation use try/catch inside the loop — that's intentional, don't remove it)
|
|
863
|
-
- [ ] `format()` renders every field in the output schema — enforced at lint time via sentinel injection, startup fails with `format-parity` errors otherwise. Different clients forward different surfaces (Claude Code → `structuredContent`, Claude Desktop → `content[]`); both must carry the same data. Primary fix: render the missing field in `format()` (
|
|
865
|
+
- [ ] `format()` renders every field in the output schema — enforced at lint time via sentinel injection, startup fails with `format-parity` errors otherwise. Different clients forward different surfaces (Claude Code → `structuredContent`, Claude Desktop → `content[]`); both must carry the same data. Primary fix: render the missing field in `format()` (for list/detail variants, one flat `z.object` with a `kind` discriminator and presence-based optional arms rendered by independent `if` blocks — `tool()` rejects a `z.discriminatedUnion` output). Escape hatch: if the output schema was over-typed for a genuinely dynamic upstream API, relax it (`z.object({}).passthrough()`) rather than maintaining aspirational typing
|
|
864
866
|
- [ ] Agent-facing context (empty-result notices, query/filter echo, pagination totals) declared in an `enrichment` block and populated via `ctx.enrich(...)` — reaches both `structuredContent` and `content[]` automatically, not authored solely in `format()` text. Enrichment keys disjoint from `output` keys
|
|
865
867
|
- [ ] If wrapping external API: output schema and `format()` preserve uncertainty from sparse upstream payloads instead of inventing concrete values, and a parsed `NaN`/`null` is dropped at the parse site rather than passed to a required output field
|
|
866
868
|
- [ ] `auth` scopes declared if the tool needs authorization
|
|
@@ -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.15"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -65,7 +65,7 @@ export const fetchTool = tool('fetch_articles', {
|
|
|
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
67
|
| 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`. |
|
|
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), and a `ctx.fail` site that never forwards the declared `recovery` warns as `error-contract-recovery-unforwarded`. |
|
|
69
69
|
|
|
70
70
|
> **`recovery` is opt-in resolution, not auto-population.** The contract `recovery` is required metadata documenting the agent's next move when this failure mode fires (a forcing function for thoughtful guidance — placeholders like "Try again." get flagged by the linter). It does **not** automatically appear in runtime `data.recovery.hint` — the framework never injects it without an explicit signal at the throw site. Authors opt in by spreading `ctx.recoveryFor('reason')` into the `data` argument, the same way `ctx.fail('reason')` opts into resolving the contract `code`. What the author types at the throw site is what flows to the wire, with no hidden transformation; the resolver is just a typed lookup keyed by the same `reason` the author already typed.
|
|
71
71
|
|
|
@@ -73,6 +73,8 @@ export const fetchTool = tool('fetch_articles', {
|
|
|
73
73
|
|
|
74
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
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.
|
|
77
|
+
|
|
76
78
|
```ts
|
|
77
79
|
export const calculateTool = tool('calculate', {
|
|
78
80
|
// ...
|
|
@@ -173,6 +175,19 @@ errors: [
|
|
|
173
175
|
|
|
174
176
|
The handler doesn't catch and re-throw — letting service errors bubble unchanged keeps "logic throws, framework catches" intact. The wire payload still carries `code` + `data.reason`, and clients can switch on reason without parsing message text. What's lost is lint-time enforcement that every reason is reachable; compensate with one wire-shape test per reason.
|
|
175
177
|
|
|
178
|
+
**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
|
+
|
|
180
|
+
```ts
|
|
181
|
+
errors: [
|
|
182
|
+
{ reason: 'empty_expression', code: JsonRpcErrorCode.ValidationError,
|
|
183
|
+
when: 'Input is empty.',
|
|
184
|
+
recovery: 'Provide a non-empty expression to evaluate.',
|
|
185
|
+
thrownBy: 'service' },
|
|
186
|
+
]
|
|
187
|
+
```
|
|
188
|
+
|
|
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
|
+
|
|
176
191
|
To carry the contract `recovery` from a service throw, accept `ctx` and spread the resolver:
|
|
177
192
|
|
|
178
193
|
```ts
|
|
@@ -544,7 +559,8 @@ The linter validates the structure of `errors[]` and (when present) cross-checks
|
|
|
544
559
|
|:-----|:---------|:--------|
|
|
545
560
|
| `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. |
|
|
546
561
|
| `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. |
|
|
547
|
-
| `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
|
|
562
|
+
| `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'`. |
|
|
563
|
+
| `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. |
|
|
548
564
|
|
|
549
565
|
### Baseline codes (auto-allowed)
|
|
550
566
|
|
|
@@ -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.17"
|
|
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` | [Error contract rules](#error-contract-rules) |
|
|
56
|
+
| Error contract (conformance) | `error-contract-conformance`, `error-contract-prefer-fail`, `error-contract-unthrown`, `error-contract-recovery-unforwarded` | [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
|
|
|
@@ -94,20 +94,27 @@ Two consequences worth knowing when writing a `format()`:
|
|
|
94
94
|
|
|
95
95
|
Fires when `format()` does not render a field present in `output`. Emitted once per missing field; large schemas can produce many `format-parity` diagnostics from a single tool.
|
|
96
96
|
|
|
97
|
-
**Primary fix:** render the missing field in `format()`. For tools that return either a summary list or a detail view,
|
|
97
|
+
**Primary fix:** render the missing field in `format()`. For tools that return either a summary list or a detail view, declare **one flat `z.object`** with a `kind` discriminator and presence-based optional arms — `tool()` rejects a `z.discriminatedUnion` output root, and it does so before any lint rule runs, with a `TypeError` naming a field you never declared. Render each arm on presence, with **independent `if` blocks, never `else if`**: a flat object yields one synthetic sample with every arm populated at once, so a mutually exclusive formatter leaves the untaken arm's leaves unrendered and fails parity on each of them.
|
|
98
98
|
|
|
99
99
|
```ts
|
|
100
|
-
output: z.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
100
|
+
output: z.object({
|
|
101
|
+
kind: z.enum(['list', 'detail']).describe('Which arm this result carries'),
|
|
102
|
+
items: z.array(ItemSchema).optional().describe('Matching items — present when kind is "list"'),
|
|
103
|
+
item: ItemSchema.optional().describe('The item — present when kind is "detail"'),
|
|
104
|
+
history: z.array(HistoryEntry).optional().describe('Change history — present when kind is "detail"'),
|
|
105
|
+
}),
|
|
104
106
|
|
|
105
107
|
format: (result) => {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
+
const lines = [`Kind: ${result.kind}`];
|
|
109
|
+
if (result.items) for (const i of result.items) lines.push(`- ${i.id} — ${i.name}`);
|
|
110
|
+
if (result.item) lines.push(`Item: ${result.item.id} — ${result.item.name}`);
|
|
111
|
+
if (result.history) for (const h of result.history) lines.push(` ${h.at}: ${h.note}`);
|
|
112
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
108
113
|
}
|
|
109
114
|
```
|
|
110
115
|
|
|
116
|
+
A union nested *below* the root is fine — the walker does produce one sample per branch there. The constraint is the output root alone.
|
|
117
|
+
|
|
111
118
|
**Escape hatch:** if the output schema was over-typed for a genuinely dynamic upstream API (e.g., a third-party JSON blob whose shape you can't nail down), relax it:
|
|
112
119
|
|
|
113
120
|
```ts
|
|
@@ -146,6 +153,8 @@ Fires when the linter cannot walk the output schema to build a synthetic sample
|
|
|
146
153
|
|
|
147
154
|
Fires when an output field is nested deeper than the sentinel walker's depth limit (8). Everything at and below that path was **not evaluated** — parity for the subtree is unknown, not verified. Four array hops from the output root is enough to reach the limit, so it turns up on ordinary shapes, not just pathological ones.
|
|
148
155
|
|
|
156
|
+
**A hop is not a path segment.** The walker counts every descent, and a `union` / `discriminated_union` dispatch descends into each branch at `depth + 1` while keeping the parent's path unchanged. So a union nested in the output shape spends a level that the reported path never shows, and a warned path can read as exactly 8 hops rather than 9. Count the unions when you are working out which field to flatten.
|
|
157
|
+
|
|
149
158
|
The bound exists because every array / union / record hop multiplies the variant set, and a self-referential schema would otherwise recurse forever. What changed is the reporting: an unevaluated subtree used to be indistinguishable from a field that resolved to nothing, so it read as a pass.
|
|
150
159
|
|
|
151
160
|
**Fix:** flatten the output shape so the field sits within the limit, or verify by hand that `format()` renders it (and treat the warning as the standing reminder that the linter is not covering it).
|
|
@@ -891,7 +900,7 @@ The inverse of `error-contract-conformance`. Fires when a declared `reason` has
|
|
|
891
900
|
|
|
892
901
|
A dead entry compiles and lints clean: the typed `ctx.fail` union accepts the reason, so nothing downstream objects. The cost lands on the client, which plans around the advertised failure surface — an agent prepares for a mode the tool cannot produce, while the mode it *does* produce goes undocumented.
|
|
893
902
|
|
|
894
|
-
**Fix:** wire the missing throw, or
|
|
903
|
+
**Fix:** wire the missing throw, drop the entry, or — when the service layer produces the failure — mark the entry `thrownBy: 'service'`. Which one is right is the author's call, so the rule surfaces and does not auto-remove.
|
|
895
904
|
|
|
896
905
|
```ts
|
|
897
906
|
errors: [
|
|
@@ -904,9 +913,51 @@ async handler(input, ctx) {
|
|
|
904
913
|
// warning error-contract-unthrown — 'site_not_found' is declared but never thrown.
|
|
905
914
|
```
|
|
906
915
|
|
|
907
|
-
|
|
916
|
+
**`thrownBy: 'service'`.** A handler that mixes one local precondition with reasons its service layer throws — the factory-error-plus-`data: { reason }` pattern — draws one diagnostic per service reason, since the scan sees only the handler body. Mark those entries and they are skipped while the handler's own reasons keep being checked:
|
|
917
|
+
|
|
918
|
+
```ts
|
|
919
|
+
errors: [
|
|
920
|
+
{ reason: 'query_too_broad', code: JsonRpcErrorCode.ValidationError, when: '…', recovery: '…' },
|
|
921
|
+
{ reason: 'item_not_found', code: JsonRpcErrorCode.NotFound, when: '…', recovery: '…',
|
|
922
|
+
thrownBy: 'service' },
|
|
923
|
+
],
|
|
924
|
+
async handler(input, ctx) {
|
|
925
|
+
if (input.query === '*') throw ctx.fail('query_too_broad', 'Wildcard query');
|
|
926
|
+
return getItemService().search(input, ctx); // throws item_not_found
|
|
927
|
+
}
|
|
928
|
+
```
|
|
929
|
+
|
|
930
|
+
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.
|
|
931
|
+
|
|
932
|
+
**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.
|
|
933
|
+
|
|
934
|
+
**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`.
|
|
935
|
+
|
|
936
|
+
### error-contract-recovery-unforwarded
|
|
937
|
+
|
|
938
|
+
**Severity:** warning
|
|
939
|
+
|
|
940
|
+
Fires per literal `ctx.fail('<reason>', …)` site that does not put the contract's `recovery` on the wire.
|
|
941
|
+
|
|
942
|
+
`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.
|
|
908
943
|
|
|
909
|
-
**
|
|
944
|
+
**Fix:** forward the resolver at the site named in the diagnostic.
|
|
945
|
+
|
|
946
|
+
```ts
|
|
947
|
+
// warns
|
|
948
|
+
throw ctx.fail('rate_limited', 'Upstream rate limit exceeded');
|
|
949
|
+
|
|
950
|
+
// clean — any of
|
|
951
|
+
throw ctx.fail('rate_limited', msg, { ...ctx.recoveryFor('rate_limited') });
|
|
952
|
+
throw ctx.fail('rate_limited', msg, ctx.recoveryFor('rate_limited'));
|
|
953
|
+
throw ctx.fail('rate_limited', msg, { recovery: { hint: `Retry in ${waitSeconds}s.` } });
|
|
954
|
+
```
|
|
955
|
+
|
|
956
|
+
**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.
|
|
957
|
+
|
|
958
|
+
**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.
|
|
959
|
+
|
|
960
|
+
**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.
|
|
910
961
|
|
|
911
962
|
---
|
|
912
963
|
|
|
@@ -985,7 +1036,8 @@ Fires when an `enrichmentTrailer` key doesn't match any declared `enrichment` fi
|
|
|
985
1036
|
Fires when a tool:
|
|
986
1037
|
1. has a depth-0 input field whose name is cap-*shaped*, AND
|
|
987
1038
|
2. has at least one depth-0 array-typed `output` field, AND
|
|
988
|
-
3.
|
|
1039
|
+
3. the cap plausibly bounds that list, AND
|
|
1040
|
+
4. declares no truncation disclosure.
|
|
989
1041
|
|
|
990
1042
|
Cap-shaped means, after normalizing camelCase to snake_case (so `maxRecords` and `max_records` are one case):
|
|
991
1043
|
|
|
@@ -997,7 +1049,14 @@ Cap-shaped means, after normalizing camelCase to snake_case (so `maxRecords` and
|
|
|
997
1049
|
|
|
998
1050
|
Matched by shape rather than an enumerated list, so a new cap noun is covered on arrival instead of silently disabling the rule for that tool. Deliberately not matched: bare `count`, `size`, `n`, `rows`, `records`, and words that merely begin with the letters (`maximum`).
|
|
999
1051
|
|
|
1000
|
-
The
|
|
1052
|
+
**The `max_` arm is narrowed by what the noun counts.** `limit`, `<noun>_limit`, and the page-size idioms say what they bound in the name, so they always qualify. `max_<noun>` does not — the same spelling carries value bounds (`max_depth_km`, `maxLat`, `max_date`, `max_magnitude`) and budgets on secondary work (`max_court_lookups`, `maxCharacters`, `max_tokens`), none of which slice the array. So the counted noun has to name something the tool returns:
|
|
1053
|
+
|
|
1054
|
+
- **it correlates with a depth-0 array in `output`** — plural-insensitive, with a trailing `_count` stripped first: `max_articles` → `articles`, `max_result_count` → `results`, `maxComments` → `comments`; or
|
|
1055
|
+
- **it is a generic result container** — `results`, `records`, `items`, `rows`, `hits`, `entries`, `matches`, `count`, `page`, `docs` — which keeps `maxRecords` firing against an `articles` array whatever the domain called its list.
|
|
1056
|
+
|
|
1057
|
+
Singularization covers only the bounded suffixes above (`ies` → `y`, `ses`/`xes`/`ches`/`shes`, trailing `s`); it is not a general English pluralizer.
|
|
1058
|
+
|
|
1059
|
+
**Accepted false negative:** a domain cap naming neither an array nor a container — `max_studies` returning `documents` — goes silent. Nothing in the declaration separates it from a value bound, and the allowlist only suppresses, so it cannot bring the warning back. Declaring `truncated` / `totalCount` is the outcome the rule is chasing anyway.
|
|
1001
1060
|
|
|
1002
1061
|
**Disclosure-present (rule silent) when** any of the following is true:
|
|
1003
1062
|
- The declared `enrichment` shape has a `truncated` or `totalCount` key (`ctx.enrich.truncated()` and `ctx.enrich.total()` satisfy this).
|
|
@@ -1039,7 +1098,21 @@ validateDefinitions({ tools, truncationAllowlist: ['my_search_tool'] });
|
|
|
1039
1098
|
validateDefinitions({ tools, truncationAllowlist: false });
|
|
1040
1099
|
```
|
|
1041
1100
|
|
|
1042
|
-
**
|
|
1101
|
+
**Project config:** `scripts/lint-mcp.ts` — the CLI behind `bun run lint:mcp` and devcheck's MCP Definitions step — reads `lint.truncationAllowlist` from the project's `devcheck.config.json` and forwards it as `LintInput.truncationAllowlist`. One declaration covers every entrypoint that shells out to the linter, and it survives framework sync (the script itself does not — a scaffold's copy is replaced on the next maintenance pass).
|
|
1102
|
+
|
|
1103
|
+
```json
|
|
1104
|
+
{
|
|
1105
|
+
"lint": {
|
|
1106
|
+
"truncationAllowlist": ["my_search_tool"]
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
```
|
|
1110
|
+
|
|
1111
|
+
`"truncationAllowlist": false` disables the rule, matching the `LintInput` and env-var forms. The file is parsed with `JSON.parse`, so the key takes no inline comment; a value that is neither `false` nor an array of tool names is reported and ignored.
|
|
1112
|
+
|
|
1113
|
+
**Env var:** `MCP_LINT_TRUNCATION_ALLOWLIST` — comma-separated tool names; the literal `false` disables.
|
|
1114
|
+
|
|
1115
|
+
**Precedence:** an explicit `LintInput.truncationAllowlist` wins, then `devcheck.config.json`, then the env var. A config file that declares no `truncationAllowlist` passes nothing through, so the env var still applies — the var is the escape hatch for a project that declares nothing, not an override for one that does.
|
|
1043
1116
|
|
|
1044
1117
|
---
|
|
1045
1118
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyanheads/mcp-ts-core",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.6",
|
|
4
4
|
"mcpName": "io.github.cyanheads/mcp-ts-core",
|
|
5
5
|
"description": "Agent-native TypeScript framework for MCP servers. Includes runtime infrastructure and agent skills for building, testing, and shipping servers.",
|
|
6
6
|
"files": [
|
|
@@ -197,7 +197,7 @@
|
|
|
197
197
|
"publish-mcp": "mcp-publisher login github -token \"$(security find-generic-password -a \"$USER\" -s mcp-publisher-github-pat -w)\" && mcp-publisher publish"
|
|
198
198
|
},
|
|
199
199
|
"devDependencies": {
|
|
200
|
-
"@biomejs/biome": "2.5.
|
|
200
|
+
"@biomejs/biome": "2.5.14",
|
|
201
201
|
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
|
202
202
|
"@cloudflare/workers-types": "5.20260910.1",
|
|
203
203
|
"@duckdb/node-api": "^1.5.5-r.5",
|
|
@@ -228,7 +228,7 @@
|
|
|
228
228
|
"depcheck": "^1.4.7",
|
|
229
229
|
"diff": "^9.0.0",
|
|
230
230
|
"execa": "^10.0.1",
|
|
231
|
-
"fast-check": "^4.10.
|
|
231
|
+
"fast-check": "^4.10.1",
|
|
232
232
|
"fast-xml-parser": "^5.11.1",
|
|
233
233
|
"ignore": "^7.0.9",
|
|
234
234
|
"js-yaml": "^5.4.2",
|
package/scripts/lint-mcp.ts
CHANGED
|
@@ -14,10 +14,14 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Runtime-agnostic: works with bun, tsx, and Node.js (via ts-node/esm).
|
|
16
16
|
*
|
|
17
|
+
* Rule knobs come from the project's `devcheck.config.json` `lint` block, so one
|
|
18
|
+
* declaration covers every entrypoint that shells out to this script.
|
|
19
|
+
*
|
|
17
20
|
* @module scripts/lint-mcp
|
|
18
21
|
*/
|
|
19
22
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
20
23
|
import { join, resolve } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
21
25
|
|
|
22
26
|
// ---------------------------------------------------------------------------
|
|
23
27
|
// Import validateDefinitions — resolve from package or local source
|
|
@@ -111,6 +115,38 @@ function tryReadJson(path: string): unknown {
|
|
|
111
115
|
}
|
|
112
116
|
}
|
|
113
117
|
|
|
118
|
+
/** The `lint` block of `devcheck.config.json`, as far as this script reads it. */
|
|
119
|
+
interface LintConfig {
|
|
120
|
+
lint?: { truncationAllowlist?: unknown };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The `LintInput` knobs a project declares in `devcheck.config.json`. */
|
|
124
|
+
export interface LintOptions {
|
|
125
|
+
truncationAllowlist?: ReadonlyArray<string> | false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Reads `lint.truncationAllowlist` from the project's `devcheck.config.json`.
|
|
130
|
+
*
|
|
131
|
+
* An absent or unreadable key yields `{}`, which leaves `validateDefinitions()`
|
|
132
|
+
* to fall back to `MCP_LINT_TRUNCATION_ALLOWLIST`. A declared value is passed as
|
|
133
|
+
* `LintInput.truncationAllowlist` and therefore wins over that env var — a
|
|
134
|
+
* reviewed, version-controlled exemption is not something ambient environment
|
|
135
|
+
* should override.
|
|
136
|
+
*/
|
|
137
|
+
export function readLintOptions(configPath = resolve('devcheck.config.json')): LintOptions {
|
|
138
|
+
const allowlist = (tryReadJson(configPath) as LintConfig | undefined)?.lint?.truncationAllowlist;
|
|
139
|
+
if (allowlist === undefined) return {};
|
|
140
|
+
if (allowlist === false) return { truncationAllowlist: false };
|
|
141
|
+
if (Array.isArray(allowlist) && allowlist.every((name) => typeof name === 'string')) {
|
|
142
|
+
return { truncationAllowlist: allowlist as string[] };
|
|
143
|
+
}
|
|
144
|
+
console.warn(
|
|
145
|
+
`Warning: ${configPath} "lint.truncationAllowlist" must be an array of tool names or false — ignoring it.`,
|
|
146
|
+
);
|
|
147
|
+
return {};
|
|
148
|
+
}
|
|
149
|
+
|
|
114
150
|
async function main(): Promise<void> {
|
|
115
151
|
const files = discoverFiles();
|
|
116
152
|
|
|
@@ -163,6 +199,7 @@ async function main(): Promise<void> {
|
|
|
163
199
|
prompts,
|
|
164
200
|
serverJson,
|
|
165
201
|
...(packageJson ? { packageJson } : {}),
|
|
202
|
+
...readLintOptions(),
|
|
166
203
|
});
|
|
167
204
|
|
|
168
205
|
for (const w of report.warnings) {
|
|
@@ -187,7 +224,9 @@ async function main(): Promise<void> {
|
|
|
187
224
|
}
|
|
188
225
|
}
|
|
189
226
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
227
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
228
|
+
await main().catch((err) => {
|
|
229
|
+
console.error('lint-mcp failed:', err);
|
|
230
|
+
process.exit(1);
|
|
231
|
+
});
|
|
232
|
+
}
|
package/templates/AGENTS.md
CHANGED
|
@@ -214,7 +214,7 @@ Handlers receive a unified `ctx` object. Key properties:
|
|
|
214
214
|
|
|
215
215
|
Handlers throw — the framework catches, classifies, and formats.
|
|
216
216
|
|
|
217
|
-
**Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. Pass `ctx.recoveryFor('reason')` as the throw's data to put it on the wire (`data.recovery.hint`, mirrored into `content[]` text unless the message already contains it verbatim); override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
|
|
217
|
+
**Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. Pass `ctx.recoveryFor('reason')` as the throw's data to put it on the wire (`data.recovery.hint`, mirrored into `content[]` text unless the message already contains it verbatim); override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Forwarding it is lint-enforced per throw site (`error-contract-recovery-unforwarded`). Mark an entry the service layer throws with `thrownBy: 'service'` so `error-contract-unthrown` skips it — lint-only metadata, nothing at runtime reads it. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
|
|
218
218
|
|
|
219
219
|
```ts
|
|
220
220
|
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
|
package/templates/CLAUDE.md
CHANGED
|
@@ -214,7 +214,7 @@ Handlers receive a unified `ctx` object. Key properties:
|
|
|
214
214
|
|
|
215
215
|
Handlers throw — the framework catches, classifies, and formats.
|
|
216
216
|
|
|
217
|
-
**Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. Pass `ctx.recoveryFor('reason')` as the throw's data to put it on the wire (`data.recovery.hint`, mirrored into `content[]` text unless the message already contains it verbatim); override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
|
|
217
|
+
**Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. Pass `ctx.recoveryFor('reason')` as the throw's data to put it on the wire (`data.recovery.hint`, mirrored into `content[]` text unless the message already contains it verbatim); override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Forwarding it is lint-enforced per throw site (`error-contract-recovery-unforwarded`). Mark an entry the service layer throws with `thrownBy: 'service'` so `error-contract-unthrown` skips it — lint-only metadata, nothing at runtime reads it. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
|
|
218
218
|
|
|
219
219
|
```ts
|
|
220
220
|
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
|
package/templates/package.json
CHANGED
|
@@ -42,7 +42,13 @@ export const echoTool = tool('template_echo_message', {
|
|
|
42
42
|
|
|
43
43
|
handler(input, ctx) {
|
|
44
44
|
if (input.message.trim().length === 0) {
|
|
45
|
-
|
|
45
|
+
// Spreading ctx.recoveryFor puts the declared recovery on the wire. Without
|
|
46
|
+
// it the hint reaches neither structuredContent nor content[].
|
|
47
|
+
throw ctx.fail(
|
|
48
|
+
'empty_message',
|
|
49
|
+
'Message must contain at least one non-whitespace character.',
|
|
50
|
+
{ ...ctx.recoveryFor('empty_message') },
|
|
51
|
+
);
|
|
46
52
|
}
|
|
47
53
|
// Reaches both client surfaces with no format() plumbing.
|
|
48
54
|
ctx.enrich({ characterCount: input.message.length });
|