| 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.8","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 |
| Schema | schema-is-object, describe-on-fields, schema-serializable | Schema rules |
| Portability | schema-format-portability, schema-anyof-needs-type, schema-no-discriminator-keyword, schema-no-defs, 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.
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 very deep recursion, 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.
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') })
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 |
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)
.describe('Identifier matching [a-zA-Z_][a-zA-Z0-9_]*, max 50 chars'),
])
.optional()
.describe('Variable name. Blank values from form-based clients are treated as omitted.'),
The outer describe on the union carries the semantic load; the non-literal variant still gets its own describe so the LLM sees the regex/length constraints in JSON Schema. Only the z.literal is skipped.
schema-serializable
Severity: error
Input/output schemas must use JSON-Schema-serializable Zod types only. The MCP SDK converts schemas to JSON Schema for tools/list; non-serializable types cause a hard runtime failure.
Disallowed: z.custom(), z.date(), z.transform(), z.bigint(), z.symbol(), z.void(), z.map(), z.set(), z.function(), z.nan().
Fix: use structural equivalents. Most common swap:
z.date()
z.string().describe('ISO 8601 timestamp, e.g., 2026-04-20T12:00:00Z')
Parse the string to a Date inside the handler if you need one.
Portability rules
MCP pins JSON Schema 2020-12 as the default dialect (SEP-1613), but LLM vendors accept different subsets. A schema that passes schema-serializable can still hard-fail at OpenAI's tool validator or silently lose fields at Gemini's API surface. These rules walk the emitted JSON Schema for patterns that break cross-vendor.
Three default-on, two opt-in. Promote opt-ins via MCP_LINT_PORTABILITY=strict (env) or validateDefinitions({ portability: 'strict' }) when targeting multi-vendor deployments.
| Rule | Severity | Default-on? |
|---|
schema-format-portability | error | yes |
schema-anyof-needs-type | warning | yes |
schema-no-discriminator-keyword | warning | yes |
schema-no-defs | warning | only when portability: 'strict' |
schema-dialect-tag | warning | only when portability: 'strict' |
schema-format-portability