Skip to main content

api-linter

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.

Jump to install

Source facts

Repository
cyanheads/crossref-mcp-server
Last source activity
July 13, 2026 at 16:52
Detected SKILL.md language
English
Stars
3
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
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):** ```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 rules](#definition-rules) | | Format parity | `format-parity`, `format-parity-threw`, `format-parity-walk-failed` | [Format parity](#format-parity) | | Schema | `schema-is-object`, `describe-on-fields`, `schema-serializable` | [Schema rules](#schema-rules) | | Portability | `schema-format-portability`, `schema-anyof-needs-type`, `schema-no-discriminator-keyword`, `schema-no-defs`, `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`, `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-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 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)`). --- ## 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: ```ts 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: ```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 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: ```ts // Wrong input: z.array(z.string()) // Right 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: ```ts variable: z .union([ z.literal(''), // form-client sentinel — no describe needed 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: ```ts // Wrong z.date() // Right 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
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub