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.

Ir a la instalación

Datos de origen

Repositorio
cyanheads/crossref-mcp-server
Última actividad en el origen
13 de julio de 2026 a las 16:52
Idioma detectado de SKILL.md
inglés
Estrellas
3
Forks
0

Opciones de instalación

De forma predeterminada está seleccionado el prompt que primero revisa el origen. Puedes cambiar a un comando directo o descargar una copia local.

Revisa los archivos de origen

Lee SKILL.md y los archivos complementarios que muestra SkillsMP antes de decidir si quieres instalarlo.

Mostrando SKILL.md

SKILL.md
Instrucciones de origen · Vista previa de solo lectura
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
Ver en GitHub
Este SKILL.md es muy grande, por eso SkillsMP muestra aqui solo la primera seccion. Ver en GitHub