- name
- api-linter
- description
- 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.
- metadata
- {"author":"cyanheads","version":"1.20","audience":"external","type":"reference"}
## Overview
The linter validates tool, resource, and prompt definitions against the MCP spec and framework conventions. **It is build-time only — not invoked at server startup.** It runs in two places:
| Entry point | When | On failure |
|:------------|:-----|:-----------|
| `bun run lint:mcp` | Manual or CI | Imports every definition file, prints errors + warnings, exits non-zero on errors. A file that fails to import is an error ([`definition-import-failed`](#definition-import-failed)), never a skip. |
| `bun run devcheck` | Pre-commit workflow | Wraps `lint:mcp` alongside typecheck, format, `bun audit`, `bun outdated`. |
Both surface the same `LintReport` from `validateDefinitions()` (exported from `@cyanheads/mcp-ts-core/linter`), plus two load errors the CLI raises itself, because `validateDefinitions()` only receives what already loaded: `definition-import-failed` and `server-json-parse`. Each diagnostic has a stable `rule` ID — that's the anchor you land on via the `See: framework-skills/api-linter/SKILL.md#<rule>` breadcrumb appended to every message.
**Severity:**
- **error** — MUST-level spec violation; blocks `devcheck`.
- **warning** — SHOULD-level or quality issue; logged but `devcheck` continues.
**Imports (if you need to run the linter programmatically):**
```ts
import { validateDefinitions } from '@cyanheads/mcp-ts-core/linter';
import type { LintReport, LintDiagnostic } from '@cyanheads/mcp-ts-core/linter';
const report = validateDefinitions({ tools, resources, prompts, serverJson, packageJson });
if (!report.passed) process.exit(1);
```
---
## Rule index
Grouped by family. Jump to any rule ID via its anchor.
| Family | Rules | Section |
|:-------|:------|:--------|
| Definition | `definition-invalid`, `definition-import-failed` | [Definition rules](#definition-rules) |
| Format parity | `format-parity`, `format-parity-threw`, `format-parity-walk-failed`, `format-parity-depth-limit` | [Format parity](#format-parity) |
| Schema | `schema-is-object`, `describe-on-fields`, `schema-serializable`, `schema-unsatisfiable`, `header-param-designation`, `schema-root-meta-discarded` | [Schema rules](#schema-rules) |
| Portability | `schema-format-portability`, `schema-anyof-needs-type`, `schema-no-discriminator-keyword`, `schema-no-defs`, `schema-root-oneof-portability`, `schema-dialect-tag` | [Portability rules](#portability-rules) |
| Names | `name-required`, `name-format`, `name-unique` | [Name rules](#name-rules) |
| Tools | `description-required`, `handler-required`, `auth-type`, `auth-scope-format`, `annotation-type`, `annotation-coherence`, `input-alias-conflict`, `meta-ui-type`, `meta-ui-resource-uri-required`, `meta-ui-resource-uri-scheme`, `app-tool-resource-pairing`, `canvas-consumer-missing` | [Tool rules](#tool-rules) |
| Resources | `uri-template-required`, `uri-template-valid`, `resource-name-not-uri`, `template-params-align` | [Resource rules](#resource-rules) |
| Landing | `landing-*` (23 rules — shape, tagline, logo, links, repo, envExample, connectSnippets, theme) | [Landing config rules](#landing-config-rules) |
| Prompts | `generate-required` | [Prompt rules](#prompt-rules) |
| Handler body | `prefer-mcp-error-in-handler`, `prefer-error-factory`, `preserve-cause-on-rethrow`, `no-stringify-upstream-error` | [Handler body rules](#handler-body-rules) |
| 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) |
| Error contract (conformance) | `error-contract-conformance`, `error-contract-prefer-fail`, `error-contract-unthrown`, `error-contract-recovery-unforwarded` | [Error contract rules](#error-contract-rules) |
| 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) |
| server.json | ~40 rules prefixed `server-json-*` | [server.json rules](#server-json-rules) |
---
## Definition rules
### definition-invalid
**Severity:** error
Fires when a `tools`, `resources`, or `prompts` array passed to `validateDefinitions()` contains a `null`/`undefined` entry (or any non-object value) instead of a definition object — e.g. a stray import or a conditional that yields `undefined`/`false`. The bad entry is reported as this diagnostic and skipped, rather than crashing the whole lint run.
**Fix:** remove the empty slot, or ensure every element of the array is a real definition object (e.g. `[makeFooTool(), enabled ? makeBarTool() : null].filter(Boolean)`).
### definition-import-failed
**Severity:** error
Fires when a discovered definition file (`*.tool.ts`, `*.resource.ts`, `*.prompt.ts`, `*.app-tool.ts`, `*.app-resource.ts` under `src/mcp-server/` or `examples/mcp-server/`) rejects on `import()`: a package that the file, or anything it imports, needs cannot be resolved, the file has a syntax error, or code throws at module load. None of that file's definitions can be checked, so the run fails rather than passing without them. The other files are still imported and linted, so one run reports every import failure alongside the rule diagnostics. The `lint:mcp` CLI (`scripts/lint-mcp.ts`) raises it; `validateDefinitions()` never does, since a programmatic caller does its own imports.
```text
✗ [definition-import-failed] src/mcp-server/tools/definitions/query.tool.ts: Cannot find package '@duckdb/node-api' imported from …
```
**Fix**, by cause:
- **An optional peer dependency** imported at the top of the definition or of a service it imports: install the peer wherever `lint:mcp` and `devcheck` run (a `devDependency` is enough), or make the import lazy — `await import('<pkg>')` inside the handler or service method that uses it, so loading the definition never touches the package. The framework's own Tier 3 subpaths already lazy-load their peers; importing them from a definition needs nothing installed.
- **A throw at module load** — reading config, constructing a client, or awaiting a network call at top level: move the work into `setup()`, a service's init, or the handler. Definitions must import without side effects.
- **A syntax error**: fix it. `devcheck`'s typecheck reports the same file with a location.
- **Running the script under plain `node`**: Node's type stripping does not rewrite a relative `./x.js` specifier to `x.ts`, so a definition that imports a sibling module fails to load. Run it under Bun — `bun run lint:mcp`, as `devcheck` does.
---
## Format parity
Why this family exists: different MCP clients forward different surfaces of a tool response to the model. Claude Code reads `structuredContent` (from your handler's return value, typed by `output`). Claude Desktop reads `content[]` (from your `format()` function). Every field must be visible on both surfaces or one class of client sees less than another. The linter enforces this by synthesizing a sample value where every leaf is a uniquely identifiable sentinel, calling `format()` once, then verifying each sentinel (or its key name, for permissive types like booleans) appears in the rendered text.
**How leaves are matched.** Two strategies, picked by leaf type:
| Leaf type | Sentinel | Match |
|:--|:--|:--|
| string | `MCPPARITY<path>` — alphanumeric only | substring, anywhere in the rendered text |
| number / int / bigint | a large distinctive integer | substring, retried against locale digit grouping (`900,000,001` → `900000001`) |
| boolean, enum member, literal, unrecognized type | the value the schema dictates (`true`, the first enum member, the literal) | **delimited token** — must not be flanked by another alphanumeric or `_`; falls back to the field's key name as a whole word or camelCase segment |
Two consequences worth knowing when writing a `format()`:
- **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.
- **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.
### format-parity
**Severity:** error
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.
**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.
```ts
output: z.object({
kind: z.enum(['list', 'detail']).describe('Which arm this result carries'),
items: z.array(ItemSchema).optional().describe('Matching items — present when kind is "list"'),
item: ItemSchema.optional().describe('The item — present when kind is "detail"'),
history: z.array(HistoryEntry).optional().describe('Change history — present when kind is "detail"'),
}),
format: (result) => {
const lines = [`Kind: ${result.kind}`];
if (result.items) for (const i of result.items) lines.push(`- ${i.id} — ${i.name}`);
if (result.item) lines.push(`Item: ${result.item.id} — ${result.item.name}`);
if (result.history) for (const h of result.history) lines.push(` ${h.at}: ${h.note}`);
return [{ type: 'text', text: lines.join('\n') }];
}
```
A union nested *below* the root is fine — the walker does produce one sample per branch there. The constraint is the output root alone.
**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:
```ts
output: z.object({}).passthrough()
```
`passthrough()` still flows the full payload to `structuredContent` without declaring each field, so the linter has nothing to check against and you're not maintaining aspirational typing.
**Anti-pattern:** summary-only `format()` like `return [{ type: 'text', text: \`Found ${n} items\` }]`. The sentinel walk will flag every field in the items array. Don't "fix" this by removing fields from `output` — that makes `structuredContent` clients blind too.
### format-parity-threw
**Severity:** warning
Fires when `format()` throws while being called with a synthetic sample. The linter cannot verify parity because your formatter crashed before producing output.
**Fix:** `format()` must be **total** — render any valid value of the output schema without throwing. Common causes:
- Assuming an optional array is always present (`result.items.map(...)` when `items` could be `undefined`)
- Dereferencing a discriminated-union branch without checking the discriminator
- Calling `toFixed()` or `toISOString()` on a value that could legitimately be any number/string
Add narrow guards. The linter feeds a synthetic but schema-valid value; if your formatter can't handle it, real inputs will eventually hit the same path.
### format-parity-walk-failed
**Severity:** warning
Fires when the linter cannot walk the output schema to build a synthetic sample (usually because the schema uses an unusual composition the walker doesn't recognize). Parity is not verified for that tool — nothing is broken at runtime, but the check is silently disabled.
**Fix:** inspect the walker error message in the diagnostic. Usually caused by custom Zod extensions or mixing Zod 3 and 4 schema internals. File an issue against `@cyanheads/mcp-ts-core` with the schema shape — this is a linter gap, not user error.
### format-parity-depth-limit
**Severity:** warning
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.
**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.
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.
**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).
---
## Schema rules
### schema-is-object
**Severity:** error
Tool `input`/`output` and prompt `args` must be `z.object({...})` at the top level (not `z.string()`, `z.array(...)`, etc.). The MCP spec requires a keyed structure at the schema root.
**Fix:** wrap whatever you had in a single-key object:
```ts
// Wrong
input: z.array(z.string())
// Right
在 GitHub 查看