| 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.12","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 | Prints errors + warnings, exits non-zero on errors. |
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). Each diagnostic has a stable rule ID — that's the anchor you land on via the See: 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):
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 rules |
| Format parity | format-parity, format-parity-threw, format-parity-walk-failed, format-parity-depth-limit | Format parity |
| Schema | schema-is-object, describe-on-fields, schema-serializable, schema-unsatisfiable, header-param-designation | 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 |
| Names | name-required, name-format, name-unique | Name rules |
| Tools | description-required, handler-required, auth-type, auth-scope-format, annotation-type, annotation-coherence, meta-ui-type, meta-ui-resource-uri-required, meta-ui-resource-uri-scheme, app-tool-resource-pairing, canvas-consumer-missing | Tool rules |
| Resources | uri-template-required, uri-template-valid, resource-name-not-uri, template-params-align | Resource rules |
| Landing | landing-* (23 rules — shape, tagline, logo, links, repo, envExample, connectSnippets, theme) | Landing config rules |
| Prompts | generate-required | Prompt rules |
| Handler body | prefer-mcp-error-in-handler, prefer-error-factory, preserve-cause-on-rethrow, no-stringify-upstream-error | 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-recovery-required, error-contract-recovery-empty, error-contract-recovery-min-words | Error contract rules |
| Error contract (conformance) | error-contract-conformance, error-contract-prefer-fail | 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 |
| server.json | ~40 rules prefixed server-json-* | 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)).
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. 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, use z.discriminatedUnion so each branch is walked separately:
output: z.discriminatedUnion('mode', [
z.object({ mode: z.literal('list'), items: z.array(ItemSchema) }),
z.object({ mode: z.literal('detail'), item: ItemSchema, history: z.array(HistoryEntry) }),
]),
format: (result) => {
if (result.mode === 'list') return renderList(result.items);
return renderDetail(result.item, result.history);
}
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:
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 makesstructuredContent` 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.
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:
input: z.array(z.string())
input: z.object({ items: z.array(z.string()).describe('List of items') })
One exception, on tool input only: a z.discriminatedUnion(...) of object variants is accepted, for a multi-mode tool with mutually exclusive argument sets. It advertises as {"type": "object", "oneOf": [...]} — the object requirement holds, and each branch keeps its own required list and const-tagged discriminator. A bare z.union(...) is still rejected: with no discriminator the model has no key to pick a branch by. Output roots stay object-only — the 2025-era projection rewrites a non-object output root and wraps structuredContent to match.
The other schema rules walk every variant, so a missing .describe(), a non-serializable type, or an unsatisfiable node inside one branch is reported at input|<i>.<field>.
describe-on-fields
Severity: warning
Every field in input, output, params, or args needs a .describe('...') call. Descriptions ship to the client and the LLM — missing ones make tools harder to use correctly.
Fix: add .describe('...') to the paths the linter flags. The diagnostic names which path is missing a description (e.g., input.filters.status).
Recursion rules — the linter walks selectively; primitive array elements are intentionally skipped. Knowing what's walked prevents over-application of describes that end up as noise in the generated JSON Schema.
| Schema position | Walked? | Describe required on inner? |
|---|
z.object({ ... }) field | Yes | Yes, on each field |
z.array(compound) element — object, array, or union | Yes | Yes, on the element |
z.array(primitive) element — string, number, enum, regex-branded primitive, etc. | No | No — outer array describe is sufficient |
z.union([a, b, ...]) non-literal option | Yes | Yes, on each option |
z.union([..., z.literal(X), ...]) literal option | No | No — outer union describe is sufficient |
A tool input root that is a z.discriminatedUnion(...) — its variant objects | Yes, their fields | No, not on the variant itself — it is a root, and roots carry no describe |
The asymmetry that catches agents: inside z.union([z.string(), z.array(z.string())]), the outer z.string() option does need a describe (unions walk non-literal options), but the z.string() inside the inner array does not (arrays don't walk primitive elements). If the linter didn't flag a path, don't add a describe there — the redundant describe ships to the JSON Schema as clutter.
Literal variants are exempt because they carry no independent semantic content — they're structural markers. The canonical case is form-client blank tolerance, where a z.literal('') variant is threaded into a union alongside a validated string so empty submissions from MCP Inspector / web UIs round-trip without breaking schema-level validation:
variable: z
.union([
z.literal(''),
z.string().max(50).regex(/^[a-z_][a-z0-9_]*$/i)