@cyanheads/mcp-ts-core 0.13.5 → 0.13.7
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 +6 -6
- package/CLAUDE.md +6 -6
- package/README.md +55 -52
- package/biome.json +2 -2
- package/changelog/0.13.x/0.13.6.md +49 -0
- package/changelog/0.13.x/0.13.7.md +77 -0
- package/config/tsconfig.base.json +2 -2
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +42 -11
- package/dist/config/index.js.map +1 -1
- package/dist/core/app.d.ts.map +1 -1
- package/dist/core/app.js +21 -4
- package/dist/core/app.js.map +1 -1
- package/dist/core/context.d.ts +9 -1
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +4 -13
- package/dist/core/context.js.map +1 -1
- package/dist/core/worker.d.ts.map +1 -1
- package/dist/core/worker.js +7 -1
- package/dist/core/worker.js.map +1 -1
- 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/mcp-server/handlerContext.d.ts +6 -0
- package/dist/mcp-server/handlerContext.d.ts.map +1 -1
- package/dist/mcp-server/handlerContext.js +3 -0
- package/dist/mcp-server/handlerContext.js.map +1 -1
- package/dist/mcp-server/prompts/prompt-registration.d.ts.map +1 -1
- package/dist/mcp-server/prompts/prompt-registration.js +6 -3
- package/dist/mcp-server/prompts/prompt-registration.js.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js +5 -1
- 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 +15 -5
- package/dist/mcp-server/transports/http/httpErrorHandler.js.map +1 -1
- package/dist/storage/core/IStorageProvider.d.ts +5 -2
- package/dist/storage/core/IStorageProvider.d.ts.map +1 -1
- package/dist/storage/core/providerHelpers.d.ts +29 -8
- package/dist/storage/core/providerHelpers.d.ts.map +1 -1
- package/dist/storage/core/providerHelpers.js +49 -11
- package/dist/storage/core/providerHelpers.js.map +1 -1
- package/dist/storage/providers/cloudflare/d1Provider.js +4 -4
- package/dist/storage/providers/cloudflare/d1Provider.js.map +1 -1
- package/dist/storage/providers/cloudflare/kvProvider.d.ts +2 -0
- package/dist/storage/providers/cloudflare/kvProvider.d.ts.map +1 -1
- package/dist/storage/providers/cloudflare/kvProvider.js +11 -9
- package/dist/storage/providers/cloudflare/kvProvider.js.map +1 -1
- package/dist/storage/providers/cloudflare/r2Provider.d.ts.map +1 -1
- package/dist/storage/providers/cloudflare/r2Provider.js +8 -5
- package/dist/storage/providers/cloudflare/r2Provider.js.map +1 -1
- package/dist/storage/providers/fileSystem/fileSystemProvider.d.ts +1 -0
- package/dist/storage/providers/fileSystem/fileSystemProvider.d.ts.map +1 -1
- package/dist/storage/providers/fileSystem/fileSystemProvider.js +10 -8
- package/dist/storage/providers/fileSystem/fileSystemProvider.js.map +1 -1
- package/dist/storage/providers/inMemory/inMemoryProvider.d.ts +5 -0
- package/dist/storage/providers/inMemory/inMemoryProvider.d.ts.map +1 -1
- package/dist/storage/providers/inMemory/inMemoryProvider.js +9 -5
- package/dist/storage/providers/inMemory/inMemoryProvider.js.map +1 -1
- package/dist/storage/providers/supabase/supabaseProvider.d.ts.map +1 -1
- package/dist/storage/providers/supabase/supabaseProvider.js +5 -1
- package/dist/storage/providers/supabase/supabaseProvider.js.map +1 -1
- package/dist/testing/index.d.ts +6 -4
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +6 -4
- package/dist/testing/index.js.map +1 -1
- package/dist/types-global/errors.d.ts +18 -0
- package/dist/types-global/errors.d.ts.map +1 -1
- package/dist/utils/formatting/partialResult.d.ts +28 -2
- package/dist/utils/formatting/partialResult.d.ts.map +1 -1
- package/dist/utils/formatting/partialResult.js +46 -2
- package/dist/utils/formatting/partialResult.js.map +1 -1
- package/dist/utils/internal/error-handler/errorHandler.d.ts +8 -2
- package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
- package/dist/utils/internal/error-handler/errorHandler.js +27 -16
- package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
- package/dist/utils/internal/error-handler/mappings.d.ts +1 -0
- package/dist/utils/internal/error-handler/mappings.d.ts.map +1 -1
- package/dist/utils/internal/error-handler/mappings.js +1 -0
- package/dist/utils/internal/error-handler/mappings.js.map +1 -1
- package/dist/utils/internal/performance.d.ts +5 -1
- package/dist/utils/internal/performance.d.ts.map +1 -1
- package/dist/utils/internal/performance.js +13 -8
- package/dist/utils/internal/performance.js.map +1 -1
- package/dist/utils/security/sanitization.d.ts +15 -15
- package/dist/utils/security/sanitization.d.ts.map +1 -1
- package/dist/utils/security/sanitization.js +108 -88
- package/dist/utils/security/sanitization.js.map +1 -1
- package/dist/utils/telemetry/instrumentation.d.ts +6 -2
- package/dist/utils/telemetry/instrumentation.d.ts.map +1 -1
- package/dist/utils/telemetry/instrumentation.js +23 -8
- package/dist/utils/telemetry/instrumentation.js.map +1 -1
- package/framework-skills/add-app-tool/SKILL.md +12 -18
- package/framework-skills/add-prompt/SKILL.md +3 -1
- package/framework-skills/add-provider/SKILL.md +14 -4
- package/framework-skills/add-resource/SKILL.md +3 -3
- package/framework-skills/add-service/SKILL.md +5 -2
- package/framework-skills/add-tool/SKILL.md +25 -8
- package/framework-skills/api-canvas/SKILL.md +2 -2
- package/framework-skills/api-config/SKILL.md +4 -3
- package/framework-skills/api-context/SKILL.md +8 -5
- package/framework-skills/api-errors/SKILL.md +25 -5
- package/framework-skills/api-linter/SKILL.md +95 -22
- package/framework-skills/api-telemetry/SKILL.md +9 -4
- package/framework-skills/api-testing/SKILL.md +21 -13
- package/framework-skills/api-utils/SKILL.md +3 -3
- package/framework-skills/api-utils/references/security.md +7 -6
- package/framework-skills/code-simplifier/SKILL.md +31 -18
- package/framework-skills/design-mcp-server/SKILL.md +62 -35
- package/framework-skills/git-wrapup/SKILL.md +16 -10
- package/framework-skills/maintenance/SKILL.md +2 -2
- package/framework-skills/orchestrations/SKILL.md +1 -1
- package/framework-skills/orchestrations/workflows/greenfield-build.md +15 -8
- package/framework-skills/polish-docs-meta/SKILL.md +2 -2
- package/framework-skills/polish-docs-meta/references/package-meta.md +1 -1
- package/framework-skills/polish-docs-meta/references/readme.md +3 -3
- package/framework-skills/release-and-publish/SKILL.md +6 -4
- package/framework-skills/release-pr-review/SKILL.md +18 -1
- package/framework-skills/report-issue-framework/SKILL.md +2 -2
- package/framework-skills/report-issue-local/SKILL.md +3 -3
- package/framework-skills/security-pass/SKILL.md +11 -3
- package/framework-skills/tool-defs-analysis/SKILL.md +3 -3
- package/package.json +16 -37
- package/scripts/lint-mcp.ts +43 -4
- package/templates/.env.example +3 -1
- package/templates/.github/ISSUE_TEMPLATE/feature_request.yml +1 -1
- package/templates/AGENTS.md +3 -3
- package/templates/CLAUDE.md +3 -3
- package/templates/Dockerfile +4 -4
- package/templates/devcheck.config.json +1 -0
- package/templates/package.json +4 -4
- package/templates/src/mcp-server/prompts/definitions/echo.prompt.ts +2 -4
- package/templates/src/mcp-server/resources/definitions/echo-app-ui.app-resource.ts +51 -14
- package/templates/src/mcp-server/resources/definitions/echo.resource.ts +1 -1
- package/templates/src/mcp-server/tools/definitions/echo-app.app-tool.ts +2 -3
- package/templates/src/mcp-server/tools/definitions/echo.tool.ts +8 -2
- package/dist/utils/telemetry/index.d.ts +0 -12
- package/dist/utils/telemetry/index.d.ts.map +0 -1
- package/dist/utils/telemetry/index.js +0 -12
- package/dist/utils/telemetry/index.js.map +0 -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.30"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -24,13 +24,13 @@ Tools use the `tool()` builder from `@cyanheads/mcp-ts-core`. Each tool lives in
|
|
|
24
24
|
|
|
25
25
|
## Naming
|
|
26
26
|
|
|
27
|
-
Tools use lowercase snake_case with a canonical server/domain prefix
|
|
27
|
+
Tools use lowercase snake_case with a canonical server/domain prefix, `{server}_{verb}_{noun}` by default. Drop the noun only when the verb is a complete action on its own (`git_pull`, `git_status`): if `{server}_{verb}` leaves "…what?" unanswered — `search` what? `connect` to what? — the noun is missing. The full rule is the Name row of the `design-mcp-server` Design table.
|
|
28
28
|
|
|
29
29
|
Examples: `pubmed_search_articles`, `pubmed_fetch_fulltext`, `clinicaltrials_find_eligible`.
|
|
30
30
|
|
|
31
31
|
The server prefix is judged on clarity, not length: the brand name or the plain well-known word for the domain both pass (`pubmed_`, `patents_`); an abbreviation fails only when it reads as something else out of context (`loc_`, `ct_`). A fourth segment is fine when the noun is inherently two words (`openfda_search_device_clearances`). When a name resists the schema — can't pick a verb, noun feels generic, the *verb* wants a second word — that's usually a signal the scope is fuzzy; split the tool, rename, or reconsider.
|
|
32
32
|
|
|
33
|
-
For shape selection (Workflow or
|
|
33
|
+
For shape selection (Workflow, Instruction, or Reference variants — standard single-action tools are the default), see the `design-mcp-server` skill's Tool shapes section.
|
|
34
34
|
|
|
35
35
|
## Template
|
|
36
36
|
|
|
@@ -243,7 +243,7 @@ const { enableWrites } = getServerConfig();
|
|
|
243
243
|
|
|
244
244
|
// The suggestion is emitted only under the config that registers its target.
|
|
245
245
|
const nextToolSuggestions = enableWrites
|
|
246
|
-
? [{ toolName: 'brapi_submit_observations', args: { studyDbId } }]
|
|
246
|
+
? [{ toolName: 'brapi_submit_observations', reason: 'Record the observations collected for this study.', args: { studyDbId } }]
|
|
247
247
|
: [];
|
|
248
248
|
|
|
249
249
|
return {
|
|
@@ -344,11 +344,11 @@ The handler dispatches on the discriminator and TypeScript narrows `input` to th
|
|
|
344
344
|
|
|
345
345
|
What reaches the wire is `{"type": "object", "oneOf": [<branch>, …]}`: branches intact, each with its own `required` list and a `const`-tagged discriminator, `additionalProperties: false` on every one. Identical bytes on a 2025-11-25 and a 2026-07-28 connection — the legacy projection inspects `outputSchema` alone and never rewrites an input root.
|
|
346
346
|
|
|
347
|
-
|
|
347
|
+
Four constraints:
|
|
348
348
|
|
|
349
349
|
- **The union must be discriminated.** A bare `z.union(...)` is rejected: with no literal-tagged key the model has nothing to choose a branch by, and every variant's `required` would read as applying at once.
|
|
350
350
|
- **`output` stays a flat `z.object`** — see the widening section below for why a non-object output root breaks the success path. When the *result* shape varies by mode, use a `kind` discriminator with presence-based optional fields and render each arm on field presence in `format()`.
|
|
351
|
-
- **
|
|
351
|
+
- **Claude clients flatten the union root.** The Anthropic Messages API rejects a top-level `oneOf` in `input_schema`, so Claude clients rewrite the root before the model sees it — and the rewrite keeps only the first branch's properties, with `required: []`. A tool that must work in Claude clients takes a flat `z.object()` with an enum discriminator, optional per-mode fields, each mode's required fields named in the discriminator's `.describe()`, and the combination checked in the handler. `schema-root-oneof-portability` (strict mode only) flags the union root. Tracked in [#510](https://github.com/cyanheads/mcp-ts-core/issues/510).
|
|
352
352
|
- **A union root rules out `headerParam`.** See below — the branches sit under `oneOf`, which the reachability rule excludes.
|
|
353
353
|
|
|
354
354
|
### `headerParam` mirrors an argument into a request header
|
|
@@ -539,7 +539,7 @@ async handler(input, ctx) {
|
|
|
539
539
|
|
|
540
540
|
Single-item tools don't need this — they either succeed or throw. The partial success question only arises with array inputs.
|
|
541
541
|
|
|
542
|
-
**Telemetry:** The framework automatically detects this pattern — when a handler result contains a non-empty `failed` array, the span gets `mcp.tool.partial_success`, `mcp.tool.batch.succeeded_count
|
|
542
|
+
**Telemetry:** The framework automatically detects this pattern — when a handler result contains a non-empty `failed` array, the span gets `mcp.tool.partial_success`, `mcp.tool.batch.succeeded_count` (from the `succeeded` array), and `mcp.tool.batch.failed_count` attributes. No manual instrumentation needed. An `output` built with `partialResultSchema()` from `/utils` is read under its own `failedKey`/`succeededKey` instead — also after `.extend()`, `.pick()`, `.omit()`, or a `.shape` spread. `.partial()` and `.required()` rebuild the fields, so a schema derived that way falls back to the literal keys.
|
|
543
543
|
|
|
544
544
|
### Empty results need context
|
|
545
545
|
|
|
@@ -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)
|
|
@@ -809,6 +811,21 @@ async handler(input, ctx) {
|
|
|
809
811
|
|
|
810
812
|
The same applies to optional arrays — use `?.length` guards so empty arrays are skipped, not passed through.
|
|
811
813
|
|
|
814
|
+
When an optional string field carries a validator (`.regex()` for a date, `.min(1)` for a cursor), a permissive schema would drop the validator and a strict one would reject the blank. Keep both by mapping the blank to `undefined` *before* the validator runs:
|
|
815
|
+
|
|
816
|
+
```typescript
|
|
817
|
+
/** A blank from a form client is "unset", never a value to validate. */
|
|
818
|
+
const blankAsUnset = <T extends z.ZodType>(schema: T) =>
|
|
819
|
+
z.preprocess((value) => (value === '' ? undefined : value), schema);
|
|
820
|
+
|
|
821
|
+
input: z.object({
|
|
822
|
+
d1: blankAsUnset(z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()).describe('Earliest date, YYYY-MM-DD.'),
|
|
823
|
+
cursor: blankAsUnset(z.string().regex(/^[1-9]\d*$/).optional()).describe('Opaque continuation from the previous page.'),
|
|
824
|
+
}),
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
`toJSONSchema` emits only the inner schema for a preprocess pipe in both `io` modes, so the advertised `pattern` is unchanged; `''` parses to an absent key, a real value still hits the validator, and the handler needs no extra guard.
|
|
828
|
+
|
|
812
829
|
**Required fields are different.** If a string field is required and must be non-empty to be meaningful, `.min(1)` is correct — the client shouldn't have submitted the form without filling it in.
|
|
813
830
|
|
|
814
831
|
### Match response density to context budget
|
|
@@ -860,7 +877,7 @@ return { items: hits };
|
|
|
860
877
|
- [ ] Optional nested objects guarded for empty inner values from form-based clients (check `?.field` truthiness, not just object presence)
|
|
861
878
|
- [ ] No `console` calls — use `ctx.log` for handler logging
|
|
862
879
|
- [ ] `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()` (
|
|
880
|
+
- [ ] `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
881
|
- [ ] 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
882
|
- [ ] 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
883
|
- [ ] `auth` scopes declared if the tool needs authorization
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
DataCanvas primitive reference — a Tier 3 SQL/analytical workspace for tabular MCP servers, backed by DuckDB. Use when registering tables from upstream APIs, running ad-hoc SQL across them, and exporting results. Covers the acquire → register → query → export flow, per-table TTL, the token-sharing pattern for multi-agent collaboration, env config, and Cloudflare Workers fail-closed behavior.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "2.
|
|
7
|
+
version: "2.4"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -511,7 +511,7 @@ The merged iterable streams — the helper does not double-buffer the full sourc
|
|
|
511
511
|
| Sync or async | Caller-supplied | Forwarded to `registerTable` as-is |
|
|
512
512
|
| Sync or async | Omitted | Helper infers via `inferSchemaFromRows` over preview buffer + sentinel |
|
|
513
513
|
|
|
514
|
-
|
|
514
|
+
Pass `schema` explicitly whenever a column's type can't be read off the first rows — a fractional column whose leading values are all `0` or `null` sniffs as `BIGINT` (or `VARCHAR`), and the appender then truncates or stringifies every later value without an error. The sniff window is only as large as the preview budget, so shrinking `previewChars` widens the exposure; the same applies to `registerTable` called without a schema. Derive the schema from the row type once and pass it to both calls.
|
|
515
515
|
|
|
516
516
|
### Cancellation and partial state
|
|
517
517
|
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Reference for core and server configuration in `@cyanheads/mcp-ts-core`. Covers env var tables with defaults, priority order, server-specific Zod schema pattern, and Workers lazy-parsing requirement.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.20"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -209,8 +209,9 @@ Activated when `SUPABASE_URL` is set.
|
|
|
209
209
|
| `OTEL_ENABLED` | `openTelemetry.enabled` | `false` | Enable OpenTelemetry export |
|
|
210
210
|
| `OTEL_SERVICE_NAME` | `openTelemetry.serviceName` | `createApp` `name` → `package.json` `name` | Seeded from `createApp({ name })` when unset; an env value wins |
|
|
211
211
|
| `OTEL_SERVICE_VERSION` | `openTelemetry.serviceVersion` | `package.json` `version` | |
|
|
212
|
-
| `
|
|
213
|
-
| `
|
|
212
|
+
| `OTEL_EXPORTER_OTLP_ENDPOINT` | — | — | OTLP/HTTP base URL; resolves `tracesEndpoint` to `<base>/v1/traces` and `metricsEndpoint` to `<base>/v1/metrics` (path prefix kept) when the signal-specific variable is unset |
|
|
213
|
+
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `openTelemetry.tracesEndpoint` | — | OTLP traces endpoint URL; overrides the base, used as-is |
|
|
214
|
+
| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | `openTelemetry.metricsEndpoint` | — | OTLP metrics endpoint URL; overrides the base, used as-is |
|
|
214
215
|
| `OTEL_TRACES_SAMPLER_ARG` | `openTelemetry.samplingRatio` | `1.0` | 0–1; fraction of traces to export |
|
|
215
216
|
| `OTEL_LOG_LEVEL` | `openTelemetry.logLevel` | `INFO` | OTel SDK internal log level: `NONE` \| `ERROR` \| `WARN` \| `INFO` \| `DEBUG` \| `VERBOSE` \| `ALL` |
|
|
216
217
|
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Canonical reference for the unified `Context` object passed to every tool and resource handler in `@cyanheads/mcp-ts-core`. Covers the full interface, its `RequestContext` base, all sub-APIs (`ctx.log`, `ctx.state`, `ctx.requestInput`, `ctx.inputs`, `ctx.enrich`, `ctx.content`), and when to use each.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "2.
|
|
7
|
+
version: "2.6"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -210,7 +210,7 @@ interface ContextState {
|
|
|
210
210
|
### Usage
|
|
211
211
|
|
|
212
212
|
```ts
|
|
213
|
-
// Store — accepts any serializable value, no manual JSON.stringify needed
|
|
213
|
+
// Store — accepts any JSON-serializable value, no manual JSON.stringify needed
|
|
214
214
|
await ctx.state.set('item/123', { name: 'Widget', count: 42 });
|
|
215
215
|
await ctx.state.set('session/xyz', token, { ttl: 3600 }); // TTL in seconds
|
|
216
216
|
|
|
@@ -236,6 +236,7 @@ if (page.cursor) { /* more pages available */ }
|
|
|
236
236
|
|
|
237
237
|
- Throws `McpError(InvalidRequest)` if `tenantId` is missing. Won't happen in stdio (any auth mode) or HTTP+`MCP_AUTH_MODE=none` — both default to `'default'`. Can happen in HTTP+`MCP_AUTH_MODE=jwt`/`oauth` when the token lacks a `tid` claim (intentional fail-closed: distinct authenticated callers must not silently share state).
|
|
238
238
|
- Keys are tenant-prefixed internally; handlers never need to namespace manually.
|
|
239
|
+
- **Values round-trip as JSON** on every provider, `in-memory` included: reads return the JSON form, so a `Date` comes back as its ISO string, a `Map` as `{}`, and a returned object never shares identity with the one written. Validate reads with a schema that matches the stored form (`z.string()` for a date, not `z.date()`). A `bigint`, a cyclic reference, or a top-level `undefined`, function, or symbol throws `McpError(SerializationError)` before anything is written; in `setMany`, one such value rejects the whole batch.
|
|
239
240
|
- **Key charset:** `^[a-zA-Z0-9_.\-/]+$`, 1024 chars max, no `..`. Slashes are the namespace separator — a colon (`item:123`) throws `McpError(ValidationError)` on every call. The rule covers `list` prefixes and every key in a batch operation. `createMockContext().state` enforces it identically, so an illegal key fails in the test rather than in a deployment.
|
|
240
241
|
- **Workers persistence:** The `in-memory` provider loses data on cold starts. Use `cloudflare-kv`, `cloudflare-r2`, or `cloudflare-d1` for durable storage in Workers.
|
|
241
242
|
|
|
@@ -712,9 +713,11 @@ For tools that cap a list (i.e. have a `limit`/`per_page`/`page_size`/`max_resul
|
|
|
712
713
|
|
|
713
714
|
```ts
|
|
714
715
|
enrichment: {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
716
|
+
// Optional: truncated() writes these only when the cap is hit, and a required
|
|
717
|
+
// enrichment field left unset fails the effective-output parse on every complete result.
|
|
718
|
+
truncated: z.boolean().optional().describe('True when the list was capped.'),
|
|
719
|
+
shown: z.number().optional().describe('Number of items returned.'),
|
|
720
|
+
cap: z.number().optional().describe('The limit that was applied.'),
|
|
718
721
|
truncationCeiling: z.number().optional().describe('Upper bound for omitted items (threshold bound).'),
|
|
719
722
|
},
|
|
720
723
|
async handler(input, ctx) {
|
|
@@ -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.16"
|
|
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
|
|
@@ -190,6 +205,8 @@ throw validationError(message, {
|
|
|
190
205
|
|
|
191
206
|
Throw when the server has authoritative classification — auth failure, rate limit, schema violation, upstream 5xx, missing required input. Don't throw when "this looks wrong" depends on intent the server can't see. For mutators, surface raw pre- and post-mutation observable state in the response and let the agent decide whether it matches intent — the server can detect that the file shrunk, but only the agent knows whether it was supposed to. Tell: defensive code justified as a free rider on other work — audit it standalone, and it usually doesn't earn its keep.
|
|
192
207
|
|
|
208
|
+
A best-effort call that catches and degrades must still rethrow on `ctx.signal?.aborted`: `catch (err) { if (ctx.signal?.aborted) throw err; return degraded(); }`. One example is an enrichment lookup whose failure should return the primary result with a notice. The factory maps a cancelled handler to `RequestCancelled` only when the handler throws. A catch-all degrade turns the caller's cancellation into a "successful" response and logs a false failure warning.
|
|
209
|
+
|
|
193
210
|
---
|
|
194
211
|
|
|
195
212
|
## Error Factories (fallback)
|
|
@@ -297,7 +314,7 @@ Use factories or `McpError` directly when the code must be exact — auto-classi
|
|
|
297
314
|
|
|
298
315
|
The framework applies these steps in order — first match wins:
|
|
299
316
|
|
|
300
|
-
1. **Request signal aborted** — `ctx.signal.aborted` is `true` when the handler unwinds → `RequestCancelled`. Resolved by the tool and resource handler factories
|
|
317
|
+
1. **Request signal aborted** — `ctx.signal.aborted` is `true` when the handler unwinds → `RequestCancelled`. Resolved before the thrown value is classified at all — by the tool and resource handler factories, and by the HTTP transport's error handler against the inbound request's signal, which catches a caller that hangs up before any handler runs (mid-body, say) and answers it 499 — so it outranks every step below, `McpError` included: the caller withdrew the request, and what the handler threw on the way out does not change that. Covers every shape an abort leaves behind — a `notifications/cancelled` `reason` string, the `DOMException` named `AbortError` a reason-less cancellation produces, a service's own `McpError`, and the SDK's `SdkError(ConnectionClosed)` on transport close. The accepted cost is that an unrelated fault raised after the abort is recorded as a cancellation too; it is bounded, because the SDK writes no response for a request whose signal it aborted. A handler that throws while the signal is live is untouched by this step.
|
|
301
318
|
2. **`McpError` instance** — `error.code` is preserved as-is; no classification needed.
|
|
302
319
|
3. **SDK transport-closed rejection** — an `SdkError` carrying `SdkErrorCode.ConnectionClosed` → `RequestCancelled`. The SDK rejects every in-flight request when the transport closes, which is what a client disconnect looks like from inside a handler. Matched on the code, not the message: one of its wordings says "aborted" and would otherwise be caught by the generic abort pattern in step 6 and read as a `Timeout`. Still the rule for a throw raised where no request signal is in scope — a service, an outbound leg, a background task.
|
|
303
320
|
4. **JS constructor name** — matched against a fixed table (e.g. `ZodError` → `ValidationError`, `SyntaxError` → `ValidationError`). Note: `TypeError` is intentionally excluded — runtime TypeErrors are programmer errors, not validation failures.
|
|
@@ -447,12 +464,14 @@ const parsed = await ErrorHandler.tryCatch(
|
|
|
447
464
|
|
|
448
465
|
`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())`.
|
|
449
466
|
|
|
467
|
+
**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 `originalErrorName`, `originalMessage`, `rootCause` (`{ name, message }`), and the canonical fields and `extra` of `context`, but never a stack: `originalStack` and the full `causeChain` go to the log record only.
|
|
468
|
+
|
|
450
469
|
**Options** (`Omit<ErrorHandlerOptions, 'rethrow'>`):
|
|
451
470
|
|
|
452
471
|
| Option | Type | Required | Purpose |
|
|
453
472
|
|:-------|:-----|:--------:|:--------|
|
|
454
473
|
| `operation` | `string` | Yes | Name logged with the error |
|
|
455
|
-
| `context` | `ErrorContext` | No |
|
|
474
|
+
| `context` | `ErrorContext` | No | Structured fields merged into the log record and the thrown error's client-visible `data`; `requestId` and `timestamp` receive special treatment |
|
|
456
475
|
| `errorCode` | `JsonRpcErrorCode` | No | Code used if the caught error is not already an `McpError` |
|
|
457
476
|
| `input` | `unknown` | No | Input value sanitized and logged alongside the error |
|
|
458
477
|
| `critical` | `boolean` | No | Marks the error as critical in logs (default `false`) |
|
|
@@ -544,7 +563,8 @@ The linter validates the structure of `errors[]` and (when present) cross-checks
|
|
|
544
563
|
|:-----|:---------|:--------|
|
|
545
564
|
| `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
565
|
| `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
|
|
566
|
+
| `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'`. |
|
|
567
|
+
| `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
568
|
|
|
549
569
|
### Baseline codes (auto-allowed)
|
|
550
570
|
|
|
@@ -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.18"
|
|
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
|
|
|
@@ -85,7 +85,7 @@ Why this family exists: different MCP clients forward different surfaces of a to
|
|
|
85
85
|
|
|
86
86
|
Two consequences worth knowing when writing a `format()`:
|
|
87
87
|
|
|
88
|
-
- **The string sentinel is alphanumeric so escaping does not break it.** `content[]` is markdown carrying upstream text you do not control, so escaping
|
|
88
|
+
- **The string sentinel is alphanumeric so escaping does not break it.** `content[]` is markdown carrying upstream text you do not control, so escaping at the render boundary is correct — and it leaves an alphanumeric probe byte-identical. Escape a character only where it would change rendering, per CommonMark/GFM rules: intraword `_` (`snake_case`), a `<` that cannot open a tag (`p<0.05`), and a `[` that cannot form a link all stay raw. Agents read `content[]` as text and copy spans out of it, so a blanket escape set turns into backslash noise in their output. Markdown escaping, HTML escaping, and URL encoding all pass. You never need to carve an exception into your escape set to keep `lint:mcp` green.
|
|
89
89
|
- **Schema-dictated values must render as their own token.** A required `kind: z.enum(['full', 'outline'])` that `format()` never renders is not satisfied by the letters `full` appearing inside a longer word elsewhere in the output — `case_name_full`, `inactive`, `listing`. Render the field, or render its key name as a label.
|
|
90
90
|
|
|
91
91
|
### format-parity
|
|
@@ -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).
|
|
@@ -364,9 +373,9 @@ Fires when emitted output contains `$defs` or `$ref`. Gemini rejects these (`400
|
|
|
364
373
|
|
|
365
374
|
**Severity:** warning (only when `portability: 'strict'`)
|
|
366
375
|
|
|
367
|
-
Fires when a tool's advertised `inputSchema` has a root-level `oneOf` — that is, when `input` is a `z.discriminatedUnion(...)`. The emitted shape is valid 2020-12, every branch is a typed object, and the bytes are identical on both MCP protocol revisions.
|
|
376
|
+
Fires when a tool's advertised `inputSchema` has a root-level `oneOf` — that is, when `input` is a `z.discriminatedUnion(...)`. The emitted shape is valid 2020-12, every branch is a typed object, and the bytes are identical on both MCP protocol revisions. Vendor handling of a `oneOf` at the *parameter* root varies: a client that reads only `type` and `properties` sees a parameterless tool and drops the constraint silently rather than erroring, and Claude clients — the Anthropic Messages API rejects a top-level `oneOf` — rewrite the root to its first branch's properties, hiding every other mode from the model. Opt-in for now; whether it warns by default is tracked in [#510](https://github.com/cyanheads/mcp-ts-core/issues/510).
|
|
368
377
|
|
|
369
|
-
**Fix (
|
|
378
|
+
**Fix (for any tool that must work in Claude clients):** flatten to a single `z.object()` with a discriminator field and optional per-mode fields, and validate the combination in the handler.
|
|
370
379
|
|
|
371
380
|
### schema-dialect-tag
|
|
372
381
|
|
|
@@ -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).
|
|
@@ -1006,11 +1065,11 @@ The shape does not distinguish a cap on *how many* from an upper bound on a *val
|
|
|
1006
1065
|
A silently capped list leaves the agent unaware that results were cut off — it may treat a partial set as complete. Use `ctx.enrich.truncated({ shown, cap })` for the one-liner:
|
|
1007
1066
|
|
|
1008
1067
|
```ts
|
|
1009
|
-
// In the enrichment block:
|
|
1068
|
+
// In the enrichment block — optional, since truncated() fires only on a capped page:
|
|
1010
1069
|
enrichment: {
|
|
1011
|
-
truncated: z.boolean().describe('True when the list was capped at the limit.'),
|
|
1012
|
-
shown: z.number().describe('Number of items returned.'),
|
|
1013
|
-
cap: z.number().describe('The limit applied.'),
|
|
1070
|
+
truncated: z.boolean().optional().describe('True when the list was capped at the limit.'),
|
|
1071
|
+
shown: z.number().optional().describe('Number of items returned.'),
|
|
1072
|
+
cap: z.number().optional().describe('The limit applied.'),
|
|
1014
1073
|
},
|
|
1015
1074
|
|
|
1016
1075
|
// In the handler:
|
|
@@ -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
|
|
|
@@ -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.13"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -28,8 +28,9 @@ OTel is **off by default**. `OTEL_ENABLED=true` alone does nothing — you also
|
|
|
28
28
|
| Env var | Default | Purpose |
|
|
29
29
|
|:--------|:--------|:--------|
|
|
30
30
|
| `OTEL_ENABLED` | `false` | Master switch. Must be `true` to start the SDK. |
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
31
|
+
| `OTEL_EXPORTER_OTLP_ENDPOINT` | — | OTLP/HTTP base URL (e.g. `http://localhost:4318`). Traces go to `<base>/v1/traces`, metrics to `<base>/v1/metrics`; a path prefix is kept. |
|
|
32
|
+
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | — | OTLP/HTTP traces endpoint (e.g. `http://localhost:4318/v1/traces`). Overrides the base for traces; used as-is. |
|
|
33
|
+
| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | — | OTLP/HTTP metrics endpoint (e.g. `http://localhost:4318/v1/metrics`). Overrides the base for metrics; used as-is. |
|
|
33
34
|
| `OTEL_SERVICE_NAME` | `createApp` `name` → `package.json` `name` | `service.name` resource attribute. Seeded from `createApp({ name })` when unset; an env value wins. |
|
|
34
35
|
| `OTEL_SERVICE_VERSION` | `package.json` `version` | `service.version` resource attribute. |
|
|
35
36
|
| `OTEL_TRACES_SAMPLER_ARG` | `1.0` | Trace sampling ratio (0–1) for `TraceIdRatioBasedSampler`. |
|
|
@@ -37,6 +38,8 @@ OTel is **off by default**. `OTEL_ENABLED=true` alone does nothing — you also
|
|
|
37
38
|
|
|
38
39
|
Metrics push via `PeriodicExportingMetricReader` every **15 seconds**. Traces use `BatchSpanProcessor`.
|
|
39
40
|
|
|
41
|
+
Endpoints resolve per the [OTLP exporter spec](https://opentelemetry.io/docs/specs/otel/protocol/exporter/#endpoint-urls-for-otlphttp): the signal-specific variable as-is, else the base plus the signal path. Those two exporters are the only export path. A signal with no resolved endpoint exports nothing, OTel log records are never exported, and `NodeSDK`'s own `OTEL_METRICS_EXPORTER` / `OTEL_LOGS_EXPORTER` defaults are not consulted.
|
|
42
|
+
|
|
40
43
|
---
|
|
41
44
|
|
|
42
45
|
## Runtime support
|
|
@@ -61,6 +64,8 @@ Cloud platform detection auto-populates resource attributes:
|
|
|
61
64
|
|
|
62
65
|
Spans batch and metrics push on a 15-second cycle, so a process that exits between cycles takes its telemetry with it. `ServerHandle.shutdown()` is the drain: it stops the transport, runs the `teardown` hook, then force-flushes traces and metrics through the OTLP exporters and closes the logger.
|
|
63
66
|
|
|
67
|
+
A failed flush is logged as a warning and the logger still closes, so the final log lines survive. The usual cause is an exporter that can't reach its collector and hits the 5 s OTel shutdown ceiling.
|
|
68
|
+
|
|
64
69
|
| Trigger | Path | Exit |
|
|
65
70
|
|:--------|:-----|:-----|
|
|
66
71
|
| `SIGTERM` / `SIGINT` | `shutdown(signal)`, then an explicit exit | `0`, or `1` when the backstop fires |
|
|
@@ -107,7 +112,7 @@ Two consequences worth knowing when reading a dashboard:
|
|
|
107
112
|
| `mcp.tool.duration` / `mcp.resource.duration` | The handler **plus** validation, formatting, and the enrichment merge — time to produce the result, not time spent in handler code. An expensive `format()` shows up here. |
|
|
108
113
|
| `mcp.tool.output_bytes` / `mcp.resource.output_bytes` | The handler's returned domain value, not the assembled result. `content[]` re-renders the data the structured payload already carries, so measuring the assembly would double-count it. Nothing is recorded for a call that fails after the handler. |
|
|
109
114
|
|
|
110
|
-
`mcp.tool.partial_success` and the `mcp.tool.batch.*` counts read the same domain value, so a batch envelope (`{ succeeded, failed }`) is still detected once the result has been assembled around it.
|
|
115
|
+
`mcp.tool.partial_success` and the `mcp.tool.batch.*` counts read the same domain value, so a batch envelope (`{ succeeded, failed }`) is still detected once the result has been assembled around it. For an `output` built with `partialResultSchema()`, the arrays are read under its `failedKey`/`succeededKey`, resolved once per definition from the output schema.
|
|
111
116
|
|
|
112
117
|
Trace context propagates across boundaries via W3C `traceparent` headers. See `api-utils` → `telemetry/trace` for `withSpan`, `buildTraceparent`, `extractTraceparent`, `createContextWithParentTrace`, `injectCurrentContextInto`, `runInContext` signatures.
|
|
113
118
|
|