Agent skill

API Linter

by cyanheads in cyanheads/pubmed-mcp-server

MCP definition linter rules reference. An agent skill from cyanheads/pubmed-mcp-server.

Apache-2.0Auto-check: notesDevelopment

Install API Linter

skills CLI
$ npx skills add cyanheads/pubmed-mcp-server --skill api-linter -a claude-code

Project install by default; add -g for ~/.claude/skills/.

GitHub CLI
$ gh skill install cyanheads/pubmed-mcp-server api-linter --agent claude-code

Project scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).

Manual copy
$ git clone --depth 1 https://github.com/cyanheads/pubmed-mcp-server.git skills-src && mkdir -p .claude/skills && cp -r skills-src/framework-skills/api-linter .claude/skills/api-linter && rm -rf skills-src

Use ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.

Claude Code skills documentation · loads skills from .claude/skills/

Facts

Skill name
api-linter
GitHub stars
155
Token cost
~17k tokens
SKILL.md length
8,164 words
Files
1
Skills in repo
30
Repo updated
First seen
Licence
Apache-2.0

At a glance

MCP definition linter rules reference. An agent skill from cyanheads/pubmed-mcp-server.

  • Works in 4 steps: has a depth-0 input field whose name is… → has at least one depth-0 array-typed… → the cap plausibly bounds that list, AND → …
  • Bun run lint:mcp
  • SKILL.md covers Overview, Rule index, Definition rules and Format parity, plus 4 more sections
  • Calls bun; reaches json-schema.org

What it does

API Linter is an agent skill from cyanheads/pubmed-mcp-server. 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.

Its SKILL.md is about 17k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.

It sits in Development, covering Linting and formatting. It works with Model Context Protocol. The repository describes itself as: Search PubMed/Europe PMC, fetch articles and full text (PMC/EPMC/Unpaywall), citations, MeSH terms via MCP. STDIO or Streamable HTTP. The licence is Apache-2.0.

When your agent uses it

  • Bun run lint:mcp
  • Bun run devcheck reports a lint error
  • Warning (format-parity
  • Schema-is-object

Example prompts

  • “/api-linter”

Workflow steps

4 steps, taken from the first numbered list in SKILL.md.

  1. has a depth-0 input field whose name is cap-*shaped*, AND
  2. has at least one depth-0 array-typed output field, AND
  3. the cap plausibly bounds that list, AND
  4. declares no truncation disclosure.

What it can do on your machine

Read from SKILL.md and the folder at commit 5a417fb. It shows what the files ask for, not the result of running them.

  • Tool permissions

    Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.

    From allowed-tools in the SKILL.md frontmatter.

  • Runs code

    Shell commands in SKILL.md call:

    • bun

    From the folder's file list and the shell code blocks in SKILL.md.

  • Network

    Hosts in commands or code, which the agent is likely to contact:

    • json-schema.org

    Also links to:

    • github.com
    • modelcontextprotocol.io

    From URLs in SKILL.md, links to its own repository left out.

  • Credentials

    Names no API keys, tokens, secrets or passwords.

    From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.

Context cost

API Linter loads about 17k tokens when it runs. Until then it costs about 84 tokens; SKILL.md has 8,164 words of instructions outside code blocks.

Always · name and description, kept in context so the agent knows when to use it
~84
When it runs · the whole SKILL.md, loaded when a task matches
~17k

Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.

Safety

Auto-check: notes

The automated check noted patterns worth knowing about, such as sudo or a known installer.

  • NoteMentions a .env fileSKILL.md:556
    ANVAS_CONSUMERS=my_query_tool` in their `.env` or CI environment.

Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.

SKILL.md

The full file from cyanheads/pubmed-mcp-server at commit 5a417fb, republished under its Apache-2.0 licence (© cyanheads). 8,164 words, ~17,478 tokens.

Download SKILL.mdSave it as .claude/skills/api-linter/SKILL.md (or your agent's skills folder).
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
metadata.version
1.21
metadata.audience
external
metadata.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 pointWhenOn failure
bun run lint:mcpManual or CIImports every definition file, prints errors + warnings, exits non-zero on errors. A file that fails to import is an error (definition-import-failed), never a skip.
bun run devcheckPre-commit workflowWraps 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.

FamilyRulesSection
Definitiondefinition-invalid, definition-import-failedDefinition rules
Format parityformat-parity, format-parity-threw, format-parity-walk-failed, format-parity-depth-limitFormat parity
Schemaschema-is-object, describe-on-fields, schema-serializable, schema-unsatisfiable, header-param-designation, schema-root-meta-discardedSchema rules
Portabilityschema-format-portability, schema-anyof-needs-type, schema-no-discriminator-keyword, schema-no-defs, schema-root-oneof-portability, schema-dialect-tagPortability rules
Namesname-required, name-format, name-uniqueName rules
Toolsdescription-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-missingTool rules
Resourcesuri-template-required, uri-template-valid, resource-name-not-uri, template-params-alignResource rules
Landinglanding-* (23 rules — shape, tagline, logo, links, repo, envExample, connectSnippets, theme)Landing config rules
Promptsgenerate-requiredPrompt rules
Handler bodyprefer-mcp-error-in-handler, prefer-error-factory, preserve-cause-on-rethrow, no-stringify-upstream-errorHandler 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-wordsError contract rules
Error contract (conformance)error-contract-conformance, error-contract-prefer-fail, error-contract-unthrownError contract rules
Enrichmentenrichment-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-truncationEnrichment 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)).

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 typeSentinelMatch
stringMCPPARITY<path> — alphanumeric onlysubstring, anywhere in the rendered text
number / int / biginta large distinctive integersubstring, retried against locale digit grouping (900,000,001 → 900000001)
boolean, enum member, literal, unrecognized typethe 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 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.

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
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 positionWalked?Describe required on inner?
z.object({ ... }) fieldYesYes, on each field
z.array(compound) element — object, array, or unionYesYes, on the element
z.array(primitive) element — string, number, enum, regex-branded primitive, etc.NoNo — outer array describe is sufficient
z.union([a, b, ...]) non-literal optionYesYes, on each option
z.union([..., z.literal(X), ...]) literal optionNoNo — outer union describe is sufficient
A tool input root that is a z.discriminatedUnion(...) — its variant objectsYes, their fieldsNo, not on the variant itself — it is a root, and roots carry no describe

A self-referential schema — a Zod 4 getter that returns the schema itself (get children() { return z.array(Node) }) — is walked once. The walk tracks the schemas on its current path and stops when one re-enters, so a missing .describe() inside the recursive schema is reported at its first occurrence, not once per level. The guard is per path: a non-recursive schema reused at two sibling paths is reported at both.

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.

schema-unsatisfiable

Severity: error

Fires when a node in the emitted JSON Schema describes an empty value set — a field no value can ever satisfy. Nothing downstream reports this: the tool registers, the schema is forwarded to the model, and the argument simply can never be populated.

Evaluated on the emitted schema rather than on the Zod schema, because the two disagree in exactly the case that matters most.

What you wroteWhat is emitted
z.enum([1, 2, 3, 4, 5]) — a numeric array handed to a string-only constructor{"type": "string", "enum": []}
z.enum([]){"type": "string", "enum": []}
z.union([]){"anyOf": []}
z.never(){"not": {}}

Fix: for a closed set of non-string values, use a multi-value literal — z.literal([1, 2, 3, 4, 5]) emits {"type": "number", "enum": [1, 2, 3, 4, 5]}. For an empty enum or union, the field has no legal values at all; drop it or give it real members.

Not flagged, deliberately: allOf: [] is vacuously true (matches everything), and empty required / properties / prefixItems are absent constraints rather than impossible ones.

header-param-designation

Severity: error

Fires when a tool's input carries an x-mcp-header designation — from headerParam(schema, 'Name') or a hand-written .meta({ 'x-mcp-header': 'Name' }) — that violates one of the constraints protocol revision 2026-07-28 places on it.

ConstraintExample violation
Statically reachable through a chain of properties keysA designation on an array element, a z.record() value, any field of a discriminated-union input root (the root advertises oneOf), or a schema hoisted into $defs by .meta({ id })
Primitive-typed property — string, integer, number, booleanheaderParam(z.object({ … }), 'Region')
Non-empty RFC 9110 tokenheaderParam(z.string(), 'Bad Name') — spaces, control characters, and HTTP delimiters are all rejected
Case-insensitively unique across the whole input schema'Region' and 'REGION' on two sibling fields

Evaluated on the emitted JSON Schema, which is the same input the SDK's own scan reads — so a verdict here is the SDK's verdict.

The message names the offending field in the linter's path vocabulary: input.rows[].region for an array element, input.map.<key> for a record value, input|0.region for a union branch.

Fix: move the designation to a top-level or nested object property. For a multi-mode tool, there is no placement that works — a union input root puts every field behind oneOf; flatten the schema or drop the designation.

Why it is an error, not a warning: the SDK enforces this with a console.warn. The tool still registers, and conforming Streamable HTTP clients then exclude it from tools/list — it silently disappears with nothing reporting the gap. tool() throws on the same condition at definition time, so this rule normally fires only for a definition assembled without the builder.

Silent when the schema cannot be converted to JSON Schema at all — that is schema-serializable's diagnostic.

schema-root-meta-discarded

Severity: warning

Fires when a .describe() or .meta() on a tool's input root was discarded by strictening, so the advertised inputSchema does not carry it.

Zod keys both calls to the schema instance, in z.globalRegistry. .strict() is catchall(z.never()) — a clone with no link back to the original — so the strictened schema tool() stores inherits no entry. Ordering is therefore load-bearing, and nothing in the type signature says so:

ts
z.object({ … }).describe('An object root.')          // lost — tool() strictens after
z.object({ … }).strict().describe('An object root.') // kept — already strict, returned untouched

The loss is otherwise invisible in every direction: describe-on-fields never asks a root to describe itself, and schema-anyof-needs-type reports on the metadata that survived, so a dropped .meta({ anyOf }) reads as no anyOf at all — which matters, because anyOf with per-branch type is the portable way to publish "one of these argument sets is required".

Fix: move .strict() ahead of .describe() / .meta() on the root. The message names what was discarded and where: input for an object or union root, input|<i> for a union variant (a union is rebuilt from its strictened options, so the union's own entry and each rebuilt variant's both go).

Silent when nothing was strictened — an explicit .strict(), .passthrough(), or .catchall(...) on the root or on every variant — which is exactly the case that advertises the metadata today. Also silent for a definition assembled without the tool() builder, since nothing strictened it.

Detection happens inside tool(), the only place both the authored and the strictened instance exist; by lint time the definition holds the clone, which carries no registry entry and no way back. The record rides a symbol-keyed, non-enumerable property, so Object.keys(definition), JSON.stringify(definition), tools/list, /.well-known/mcp.json, and _meta are all unchanged.

Whether the discarded description or metadata should instead reach the wire is a separate question — that changes the advertised bytes, so it is held.


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.

RuleSeverityDefault-on?
schema-format-portabilityerroryes
schema-anyof-needs-typewarningyes
schema-no-discriminator-keywordwarningyes
schema-no-defswarningonly when portability: 'strict'
schema-dialect-tagwarningonly when portability: 'strict'
schema-format-portability

Severity: error

Fires when the emitted schema contains a format value outside the allowlist. Default = OpenAI's nine: date-time, time, date, duration, email, hostname, ipv4, ipv6, uuid — the strictest commonly-used target. OpenAI's tool validator hard-rejects unknown formats: the tool never registers and the model never sees it. Field report: cyanheads/git-mcp-server#47 (gpt-5-codex rejecting format: "uri" from z.url()).

Zod methods vs. the default allowlist:

Zod callEmitted formatAllowed?
z.email(), z.uuid(), z.iso.datetime(), z.iso.date()email / uuid / date-time / dateyes
z.url()urino — fires
z.cuid(), z.cuid2(), z.ulid(), z.nanoid(), z.base64(), z.jwt()variousno — fires

Fix: drop the format method, move the constraint into .describe() text where the model reads it:

ts
// Wrong                                  // Right
homepage: z.url().describe('Homepage')    homepage: z.string().describe('Homepage (absolute URL)')

Override: widen the allowlist when targeting only vendors that accept the format:

ts
validateDefinitions({ formatAllowlist: ['email', 'uuid', 'date-time', 'uri'], tools, resources, prompts });
schema-anyof-needs-type

Severity: warning

Fires when an anyOf/oneOf branch lacks a top-level type. Gemini rejects with 400: reference to undefined schema. Triggered by patterns like z.union([z.object({...}).nullable(), z.object({...})]) — the inner nullable emits a typeless anyOf.

Fix: prefer optionality via required-omission, or use z.discriminatedUnion for tagged unions — both emit branches with explicit type: "object".

schema-no-discriminator-keyword

Severity: warning

Fires when a schema carries the OpenAPI discriminator keyword. OpenAI silently ignores it; Gemini doesn't recognize it. Zod 4's z.discriminatedUnion emits the portable shape (oneOf of typed branches with const-tagged literals), so this rule mainly catches hand-built schemas attached via .meta({...}) or third-party-generated JSON Schema.

Fix: drop the discriminator meta — the const literals on each branch are how clients tell variants apart.

schema-no-defs

Severity: warning (only when portability: 'strict')

Fires when emitted output contains $defs or $ref. Gemini rejects these (400: reference to undefined schema). Typically caused by reused or recursive types built with z.lazy(...). Opt-in because SEP-1576 (token-bloat mitigation) is moving the community toward more $defs.

Fix: inline the recursive type with bounded depth, or accept the Gemini limitation if you target only Anthropic clients.

schema-root-oneof-portability

Severity: warning (only when portability: 'strict')

Fires when a tool's advertised inputSchema has a root-level oneOf — that is, when input is a z.discriminatedUnion(...). The emitted shape is valid 2020-12, every branch is a typed object, and the bytes are identical on both MCP protocol revisions. Vendor handling of a oneOf at the parameter root varies: a client that reads only type and properties sees a parameterless tool and drops the constraint silently rather than erroring, and Claude clients — the Anthropic Messages API rejects a top-level oneOf — rewrite the root to its first branch's properties, hiding every other mode from the model. Opt-in for now; whether it warns by default is tracked in #510.

Fix (for any tool that must work in Claude clients): flatten to a single z.object() with a discriminator field and optional per-mode fields, and validate the combination in the handler.

schema-dialect-tag

Severity: warning (only when portability: 'strict')

Fires when the top-level schema is missing $schema. SEP-1613 makes JSON Schema 2020-12 the default dialect, but explicit tagging ("$schema": "https://json-schema.org/draft/2020-12/schema") is forward-compatible — older SDK clients default to draft-07. Zod 4's toJSONSchema always emits $schema, so this rule is a no-op for Zod-only servers; it exists as forward-compat for hand-built schemas (see SEP-834).


Name rules

name-required

Severity: error

Every tool, resource, and prompt definition needs a non-empty name string. For resources, an empty name also falls back to the URI template (see resource-name-not-uri).

name-format

Severity: error

Scope: tools only — resources and prompts are checked by name-required only.

Tool names must match ^[A-Za-z0-9._-]{1,128}$ (alphanumerics, dots, hyphens, underscores; 1–128 chars). Tools conventionally use snake_case.

Fix: rename to a valid identifier. If the legacy name is user-facing, keep title as the display string and use a valid name internally.

name-unique

Severity: error

Tool names, resource names, and prompt names must each be unique within their type. Duplicates would cause the client to see only one.

Fix: rename one, or consolidate into a single definition if they're actually the same tool.


Tool rules

description-required

Severity: warning

Every tool, resource, and prompt needs a non-empty description. This is what the client shows the LLM to decide whether to call the definition. A missing description dramatically hurts selection accuracy.

Also applies to resources and prompts (same rule ID, different definitionType).

Fix: write a single cohesive paragraph. Prose, not bullet lists. Descriptions render inline in most clients.

handler-required

Severity: error

Every tool must have a handler function (or taskHandlers object for task tools). Every resource must have a handler. Definitions without handlers can't do anything at runtime.

Also applies to resources (same rule ID, different definitionType).

auth-type

Severity: error

auth must be an array of strings. A single string or other shape is rejected.

ts
// Wrong
auth: 'tool:my_tool:read'
// Right
auth: ['tool:my_tool:read']
auth-scope-format

Severity: error

Every element in auth must be a non-empty string. Empty strings in the array are rejected — they'd match anything.

annotation-type

Severity: warning

annotations hints (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) must be booleans. Strings like 'yes' or numbers are rejected — the MCP spec defines these as booleans and clients may type-check.

annotation-coherence

Severity: warning

Catches readOnlyHint: true with any explicit destructiveHint value (even false) — the destructive hint is meaningless on a read-only tool, so its presence signals authoring confusion. Drop destructiveHint entirely when the tool is read-only.

input-alias-conflict

Severity: error

Fires when a tool's inputAliases cannot resolve to exactly one declared input key. An alias is a one-to-one mapping fixed ahead of time — the reason it is accepted where nearest-key matching is not — so an alias resolving to none or to more than one is a definition error, not a runtime one. The runtime declines an ambiguous rewrite silently and the caller sees the ordinary strict rejection, which reads as the alias simply not working.

Six conditions, all decidable from the definition:

ConditionExample
An alias must not equal a declared keyinput: z.object({ q, query }) with inputAliases: { q: 'query' } — a declared key is never rewritten, so the alias can never fire
An alias's target must be a declared keyinputAliases: { q: 'searchQuery' } when the schema declares query
An alias's target must not be headerParam-designatedinputAliases: { region: 'regionCode' } with regionCode: headerParam(z.string(), 'Region') — the rewrite never targets a header-mirrored field, since the SDK checks the Mcp-Param-Region header against the body the caller sent, so the caller gets Unknown key region
Two declared keys must not case-fold to one namez.object({ maxResults, max_results }) — no alias can resolve between them
An alias must not case-fold to a declared key other than its targetinputAliases: { max_results: 'query' } alongside a declared maxResults
Two aliases must not case-fold to one name with different targetsinputAliases: { 'search-term': 'query', search_term: 'maxResults' }

Case-folding strips - and _ and lowercases — the same fold the runtime rewrite applies, so the rule and the runtime cannot disagree. On a discriminated-union root, every variant's keys count as declared: a rewrite resolves against the selected variant, so an alias naming a key no variant declares can never fire. The header check reads each variant's own designations, so an alias onto a designated field inside one variant is reported beside that variant's header-param-designation error; a designation deeper than the root never blocks an alias, which only ever names a root key.

Fix: point the alias at an existing key, rename the key it shadows, or drop the alias. For a header target, drop the alias or the headerParam designation — the field cannot be both. Also fires when inputAliases is not an object of non-empty string targets.

Silent when no inputAliases is declared — the case-style half needs no declaration and declines ambiguity on its own.

meta-ui-type

Severity: error (MCP Apps tools only)

When a tool declares _meta.ui, that field must be an object. null, arrays, or primitives are rejected.

meta-ui-resource-uri-required

Severity: error (MCP Apps tools only)

_meta.ui.resourceUri must be a non-empty string. This is the URI the client resolves to load the app UI.

meta-ui-resource-uri-scheme

Severity: warning (MCP Apps tools only)

_meta.ui.resourceUri should use the ui:// scheme. Other schemes (like https://) work but are discouraged — the ui:// convention signals the resource is meant to be hosted by the MCP server, not fetched externally.

app-tool-resource-pairing

Severity: warning (MCP Apps tools only)

An app tool's _meta.ui.resourceUri must match the uriTemplate of a registered resource. This catches the common mistake of renaming one side of the pair and forgetting the other.

Fix: either correct the resourceUri to match an existing resource, or register the resource it references. Use the add-app-tool skill's paired scaffold to avoid this.

canvas-consumer-missing

Severity: warning

Fires when the registered tool set contains at least one tool whose output schema has a depth-0 field named canvas_id or canvasId, but no consumer tool is registered — that is, no tool name ends with _dataframe_query and no extra names are listed in canvasConsumers.

A canvas token with no query path is dead output: the agent receives the token but has no tool to send it to. The fix runs in either direction:

  • Complete the integration — add the standard <prefix>_dataframe_query and <prefix>_dataframe_describe consumers (see api-canvas).
  • Remove the staging — when the data isn't row-shaped (nested, heterogeneous, single-record payloads), SQL access adds nothing. Drop the DataCanvas integration rather than adding tools to justify it.

Knob: suppress via LintInput.canvasConsumers:

ts
// Accept a non-standard query tool name:
validateDefinitions({ tools, canvasConsumers: ['my_sql_query'] });

// Disable the rule entirely:
validateDefinitions({ tools, canvasConsumers: false });

Env var: MCP_LINT_CANVAS_CONSUMERS — comma-separated tool names; the literal false disables. A programmatic LintInput.canvasConsumers takes precedence over the env var. Servers that need the knob set MCP_LINT_CANVAS_CONSUMERS=my_query_tool in their .env or CI environment.


Resource rules

uri-template-required

Severity: error

Every resource needs a non-empty uriTemplate string. The URI template is the resource's primary identifier.

uri-template-valid

Severity: error

uriTemplate must be syntactically valid per RFC 6570: balanced braces, non-empty variable names. test://{id/data (unbalanced) and test://{}/data (empty variable) are rejected.

resource-name-not-uri

Severity: warning

Warns when the resource's name defaults to the URI template because no explicit name was provided. URIs make poor display names — clients often show them verbatim.

Fix: add a short name field:

ts
resource('myscheme://{id}/data', {
  name: 'Item data',  // <-- add this
  // ...
})
template-params-align

Severity: error

Every variable in the URI template must appear as a key in the params schema. test://{itemId}/data with params: z.object({ item_id: ... }) is rejected — casing mismatches count. The check is template → schema only; extra schema keys not referenced by the template are not flagged.

Fix: rename one side so they match exactly. The error message names which variables are on which side.


Prompt rules

generate-required

Severity: error

Every prompt needs a generate function that returns the message array. Prompts without generate have nothing to produce.

(Prompts also share name-* and description-required rules from their respective families.)


Show full SKILL.md (3,449 more words)Show less

server.json rules

Validates the server.json manifest at project root against the MCP server manifest spec. Every rule below fires only when a server.json is present.

Rule IDSeverityWhat it checks
server-json-parseerrorserver.json exists but does not parse as JSON, so none of the rules below can run. Raised by the lint:mcp CLI, which reads the file; a missing server.json is skipped
server-json-typeerrorserver.json must be a JSON object, not an array or primitive
server-json-name-requirederrorname must be present and non-empty
server-json-name-lengtherrorname length 3–200 characters
server-json-name-formaterrorname must match reverse-DNS pattern owner/project
server-json-description-requirederrordescription must be present and non-empty
server-json-description-lengthwarningdescription > 100 chars — some registries truncate
server-json-version-requirederrorversion must be present
server-json-version-lengtherrorversion length ≤ 255
server-json-version-no-rangeerrorversion must be a specific version, not a range (^, ~, >=, etc.)
server-json-version-semverwarningversion should be valid semver (major.minor.patch)
server-json-version-syncwarningserver.json version should match package.json version
server-json-repository-typeerrorrepository must be an object
server-json-repository-urlerrorrepository.url is required when repository is present
server-json-repository-sourceerrorrepository.source is required when repository is present
server-json-packages-typeerrorpackages must be an array
server-json-package-typeerrorEach packages[i] must be an object
server-json-package-registryerrorpackages[i].registryType is required
server-json-package-identifiererrorpackages[i].identifier is required
server-json-package-transporterrorpackages[i].transport is required
server-json-package-no-latesterrorpackages[i].version must not be "latest" — pin a specific version
server-json-package-version-syncwarningpackages[i].version should match root version
server-json-package-args-typeerrorpackages[i].packageArguments must be an array
server-json-runtime-args-typeerrorpackages[i].runtimeArguments must be an array
server-json-env-vars-typeerrorpackages[i].environmentVariables must be an array
server-json-remotes-typeerrorremotes must be an array
server-json-remote-typeerrorEach remotes[i] must be an object
server-json-remote-transport-typeerrorremotes[i].type is required
server-json-remote-no-stdioerrorremotes[i].type must be streamable-http or sse — stdio is not valid for remotes
server-json-transport-typeerrortransport must be an object
server-json-transport-type-valueerrortransport.type must be one of stdio, streamable-http, sse
server-json-transport-url-requirederrortransport.url required for streamable-http and sse
server-json-transport-url-formatwarningtransport.url should be http:// or https://
server-json-argument-typeerrorEach argument must be an object
server-json-argument-type-valueerrorargument.type must be positional or named
server-json-argument-nameerrorNamed arguments require name
server-json-argument-valueerrorPositional arguments require value or valueHint
server-json-input-formatwarningformat should be string, number, boolean, or filepath
server-json-env-var-typeerrorEach environment variable must be an object
server-json-env-var-nameerrorEnvironment variable name is required
server-json-env-var-descriptionwarningEnvironment variables should have a description

Most of these are mechanical — fix the manifest field named in the diagnostic's message. The registry spec is the source of truth; this linter just surfaces violations before you submit.


Landing config rules

Validate the landing config passed to createApp() (the config object that drives the framework's landing page). Run only when input.landing is provided to validateDefinitions. All errors — landing config that's structurally broken would render incorrectly on the public page.

RuleSeverityCatches
landing-shapeerrorlanding is not a plain object
landing-tagline-typeerrortagline is present but not a string
landing-tagline-lengtherrortagline exceeds the max length
landing-logo-typeerrorlogo is present but not a string
landing-logo-sizeerrorlogo is too long for inline rendering
landing-links-typeerrorlinks is present but not an array
landing-links-counterrorlinks exceeds the max count
landing-link-shapeerrorA links[] entry is not a plain object
landing-link-hreferrorA link entry's href is missing or not a non-empty string
landing-link-labelerrorA link entry's label is missing or not a non-empty string
landing-repo-root-typeerrorrepoRoot is present but not a string
landing-repo-root-shapeerrorrepoRoot is not a recognized GitHub URL shape
landing-env-example-typeerrorenvExample is present but not a plain object
landing-env-example-counterrorenvExample has too many entries
landing-env-example-keyerrorAn envExample key is empty or invalid
landing-env-example-valueerrorAn envExample value is not a string
landing-connect-snippets-typeerrorconnectSnippets is present but not a plain object
landing-connect-snippets-keyerrorA connectSnippets key is empty
landing-connect-snippets-valueerrorA connectSnippets value is not a string
landing-connect-snippets-emptyerrorA connectSnippets value is an empty string
landing-theme-typeerrortheme is present but not a plain object
landing-theme-accenterrortheme.accent is present but not a string
landing-theme-accent-formaterrortheme.accent doesn't match the expected color format

Diagnostic anchors for these rules are the rule ID — e.g. framework-skills/api-linter/SKILL.md#landing-shape. Pass landing to validateDefinitions({ landing, tools, resources, prompts }) to opt in.


Handler body rules

Heuristic source-text checks that scan handler.toString() for common error-handling anti-patterns. All warnings — false positives are possible because the rules can't see code reached through wrappers, factories assigned to variables, or service-layer throws. Each rule fires at most once per handler to keep reports quiet.

prefer-mcp-error-in-handler

Severity: warning

Fires when a handler contains throw new Error(...), or throw Error(...) — the spelling Bun's transpiler prints for the same code, since it drops new from built-in error constructors. Plain Error doesn't carry a JSON-RPC code — the framework's auto-classifier degrades to InternalError, hiding the actual failure mode. Other built-ins (TypeError, RangeError) are not flagged in either spelling.

Plain Error is acceptable for "don't care" cases where the specific code doesn't matter (per CLAUDE.md/AGENTS.md: "plain Error for don't-care cases"). This rule targets domain-specific failures that deserve a concrete code — upgrade those to factories or ctx.fail, and accept the warning for the rest.

Fix: use McpError or a factory for domain-specific failures:

ts
// instead of:
throw new Error('Item not found');
// use:
throw notFound('Item not found', { itemId });
prefer-error-factory

Severity: warning

Fires when a handler builds an error via new McpError(JsonRpcErrorCode.X, ...) and a matching factory exists (notFound, rateLimited, serviceUnavailable, …). The factory form is shorter, self-documenting, and consistent with the rest of the codebase.

Fix: swap the constructor for the factory the diagnostic names:

ts
// instead of:
throw new McpError(JsonRpcErrorCode.NotFound, 'Item missing');
// use:
throw notFound('Item missing');
preserve-cause-on-rethrow

Severity: warning

Fires when a catch (e) block throws a structured McpError (or factory) without passing { cause: e }. Dropping the cause loses the original stack trace — observability platforms and pino-pretty rely on it to render error chains. When the catch binding is itself named cause, the { cause } shorthand satisfies the rule — it is also how Bun's transpiler prints { cause: cause }.

Fix: thread the cause through the 4th McpError argument or factory options:

ts
try {
  await fetchUpstream();
} catch (e) {
  throw serviceUnavailable('Upstream failed', { service: 'pubmed' }, { cause: e });
}
no-stringify-upstream-error

Severity: warning

Fires when a handler throws an error message containing JSON.stringify(...). Stringifying caught or upstream errors into the message risks leaking internal stack traces, AWS internal ARNs, or third-party trace IDs to clients.

Fix: sanitize first, or attach the raw blob to the error's data payload — never the message.

ts
// instead of:
throw new Error(`Upstream failed: ${JSON.stringify(e)}`);
// use:
throw serviceUnavailable('Upstream failed', { upstreamError: e }, { cause: e });

Error contract rules

Validate the optional errors[] declarative contract on tool/resource definitions. Structural rules check the shape of contract entries; conformance rules cross-check the handler body against the declared codes.

When a contract is declared, the handler receives a typed ctx.fail(reason, …) keyed by the declared reason union. See framework-skills/api-errors/SKILL.md for runtime semantics.

error-contract-type

Severity: error

Fires when errors is present but not an array. The contract must be a tuple of ErrorContract entries.

error-contract-empty

Severity: warning

Fires when errors: [] is declared. An empty contract is a no-op — nothing to surface in tools/list, no reason union for ctx.fail, no conformance to check.

Fix: drop the field, or declare actual failure modes.

error-contract-entry-type

Severity: error

Fires when an entry in errors[] isn't an object. Each entry must be { code, reason, when, recovery } (and optionally retryable).

error-contract-code-type

Severity: error

Fires when an entry's code is missing or not a number. Use the JsonRpcErrorCode enum:

ts
errors: [{ code: JsonRpcErrorCode.NotFound, reason: 'no_match', when: 'No items matched' }]
error-contract-code-unknown

Severity: error

Fires when an entry's code is a number but not a known JsonRpcErrorCode value. Likely a typo or stale magic number — import the enum and use a member.

error-contract-code-unknown-error

Severity: warning

Fires when an entry uses JsonRpcErrorCode.UnknownError (-32099). That code is the auto-classifier's giveup-fallback; declaring it in a contract conveys nothing useful to clients.

Fix: pick a more specific code (InternalError, ServiceUnavailable, etc.) or drop the entry.

error-contract-reason-required

Severity: error

Fires when an entry's reason is missing or empty. reason is the stable machine-readable identifier clients switch on; it must always be present.

error-contract-reason-format

Severity: warning

Fires when reason isn't snake_case (matched against ^[a-z][a-z0-9_]*$). Reasons are part of the public API — treat them like API constants. 'NotFound', 'no-match', '1bad' all warn.

Fix: rename to snake_case ('no_match', 'rate_limited', …).

error-contract-reason-unique

Severity: error

Fires when two entries in the same contract share a reason. Reasons must be unique within a contract — they're how ctx.fail(reason, …) selects the entry.

error-contract-when-required

Severity: error

Fires when an entry's when field is missing or empty. when is the human-readable explanation surfaced to LLMs and UI clients; without it, the contract is opaque.

error-contract-retryable-type

Severity: warning

Fires when an entry's optional retryable field is present but isn't a boolean. Only true or false is meaningful — drop the field if you can't commit to either.

error-contract-severity-unknown

Severity: error

Fires when an entry's optional severity field is present but isn't one of debug, info, notice, or warning. Unlike retryable, this field is not inert metadata — it selects the logger method the failure's record is emitted through, so an unrecognized value has no runtime meaning.

error is not accepted: it is the default, expressed by omitting the field. Nor are the pino spellings (warn) or other cases (WARNING) — the values are the framework logger's own level names.

Fix: use one of the four levels, or drop the field.

ts
// instead of:
{ reason: 'consent_declined', code: JsonRpcErrorCode.InvalidRequest, when: '…', severity: 'warn', recovery: '…' }
// use:
{ reason: 'consent_declined', code: JsonRpcErrorCode.InvalidRequest, when: '…', severity: 'warning', recovery: '…' }
error-contract-recovery-required

Severity: error

Fires when an entry's recovery field is missing or not a string. recovery is the agent's next-move guidance when this failure fires — the handler factory sends it as data.recovery.hint with any failure carrying the entry's reason and no hint of its own.

error-contract-recovery-empty

Severity: error

Fires when recovery is an empty string. A blank recovery is worse than none — it suggests the field was considered and deliberately left empty.

Fix: write a concrete recovery hint (≥5 words).

error-contract-recovery-min-words

Severity: warning

Fires when recovery has fewer than 5 words. Short recoveries like "Try again." are too vague to guide an agent's next action.

Fix: expand with specifics — what to try, what parameter to change, which tool to call instead.

error-contract-conformance

Severity: warning

Cross-check rule. Fires when a handler throws a non-baseline code (via new McpError(JsonRpcErrorCode.X, …) or a factory like notFound()) that isn't declared in errors[].

Baseline codes (InternalError, ServiceUnavailable, Timeout, ValidationError, SerializationError, RequestCancelled) are auto-allowed because they bubble from anywhere — services, framework utilities, the auto-classifier — and are implicitly always-possible on any tool. Only domain-specific codes need declaring.

Fix: add the missing code to errors[] with a stable reason, or route through ctx.fail(reason, …) if it maps to an existing entry.

Heuristic limitations: the scan reads handler.toString() and only counts code construction sites — new McpError(JsonRpcErrorCode.X, …) and throw factory(…). A bare JsonRpcErrorCode.X reference in a comparison (err.code === JsonRpcErrorCode.X) or a case label is not a throw and is correctly ignored. Indirect throws (const e = notFound(); throw e;), throws from called services, and throws via runtime helpers like httpErrorFromResponse(...) are invisible.

error-contract-prefer-fail

Severity: warning

Fires when a handler throws a code that is declared in the contract directly (via factory or new McpError) instead of routing through ctx.fail(reason, …). Direct throws bypass the typed helper, leaving observers without a stable data.reason and disconnecting the throw site from the contract entry.

Fix: swap the direct throw for ctx.fail using the reason the diagnostic suggests:

ts
// instead of:
throw notFound('No items match');
// use:
throw ctx.fail('no_match', 'No items match');

The diagnostic message includes the declared reason(s) for the code so you can copy-paste.

error-contract-unthrown

Severity: warning

The inverse of error-contract-conformance. Fires when a declared reason has no literal ctx.fail('<reason>' and no literal ctx.recoveryFor('<reason>' anywhere in the handler — a contract entry no code path can produce.

A dead entry compiles and lints clean: the typed ctx.fail union accepts the reason, so nothing downstream objects. The cost lands on the client, which plans around the advertised failure surface — an agent prepares for a mode the tool cannot produce, while the mode it does produce goes undocumented.

Fix: wire the missing throw, drop the entry, or — when the service layer produces the failure — mark the entry thrownBy: 'service'. Which one is right is the author's call, so the rule surfaces and does not auto-remove.

ts
errors: [
  { reason: 'no_match',       code: JsonRpcErrorCode.NotFound, when: '…', recovery: '…' },
  { reason: 'site_not_found', code: JsonRpcErrorCode.NotFound, when: '…', recovery: '…' },
],
async handler(input, ctx) {
  if (rows.length === 0) throw ctx.fail('no_match', 'No rows in range');
}
// warning  error-contract-unthrown — 'site_not_found' is declared but never thrown.

thrownBy: 'service'. A handler that mixes one local precondition with reasons its service layer throws — the factory-error-plus-data: { reason } pattern — draws one diagnostic per service reason, since the scan sees only the handler body. Mark those entries and they are skipped while the handler's own reasons keep being checked:

ts
errors: [
  { reason: 'query_too_broad', code: JsonRpcErrorCode.ValidationError, when: '…', recovery: '…' },
  { reason: 'item_not_found',  code: JsonRpcErrorCode.NotFound,        when: '…', recovery: '…',
    thrownBy: 'service' },
],
async handler(input, ctx) {
  if (input.query === '*') throw ctx.fail('query_too_broad', 'Wildcard query');
  return getItemService().search(input, ctx);   // throws item_not_found
}

The field is lint-only metadata: ctx.fail, ctx.recoveryFor, the recovery fill, the severity lookup, and the advertised error envelope never read it, so a marked entry is typed, advertised, and thrown exactly as an unmarked one. Prefer it over the workarounds that also silence the rule — moving the literal ctx.fail into a module-level helper turns the whole tool off, handler-local reasons included.

Trigger. Only when the handler holds at least one literal ctx.fail(. A handler with none produces its reasons somewhere the scan cannot reach, so firing there would warn on every service-layer definition. A ctx.fail( or ctx.recoveryFor( whose first argument is not a string literal — a variable, a template literal, a map lookup — makes the named set unknowable, and the whole definition is skipped rather than guessed at.

Heuristic limitations: the scan reads handler.toString() and matches call sites in the comment- and string-stripped text, so a ctx.fail('…') written inside a comment or nested in another literal does not count as thrown. A reason produced outside the handler closure is invisible to any toString() scan, which is why the rule can never prove absence and stays a warning. Still silent without a marker: a createFail(errors) resolver built outside the handler, and an aliased const fail = ctx.fail.

No rule checks that a throw site forwards the declared recovery: the handler factory fills data.recovery.hint from the entry for any failure carrying its reason and no hint of its own, so a bare ctx.fail('<reason>') and a service throw both reach the client with it. See api-errors.


Enrichment rules

Validate the enrichment block — the success-path counterpart to errors[]. Enrichment fields are merged into structuredContent and folded into the advertised outputSchema, so the linter guards the block's shape and its disjointness from output. See api-context's ctx.enrich and add-tool's Tool Response Design.

enrichment-type

Severity: error

Fires when enrichment is present but isn't a plain object mapping field names to Zod schemas (a ZodRawShape) — e.g. an array or a primitive.

Fix: declare enrichment: { <name>: <ZodType>, … }.

enrichment-empty

Severity: warning

Fires when enrichment: {} is declared with no fields — a no-op.

Fix: drop the field, or declare the agent-facing fields ctx.enrich(...) will populate.

enrichment-field-type

Severity: error

Fires when an enrichment field's value isn't a Zod schema.

Fix: use a Zod type (z.string().describe(…), z.number().describe(…), …) for every enrichment field.

enrichment-output-collision

Severity: error

Fires when an enrichment key matches an output key. The effective output schema is output.extend(enrichment), so a collision silently overrides the output field.

Fix: rename one side so enrichment keys are disjoint from output keys.

enrichment-prefer-block

Severity: warning

Advisory. Fires when a tool has no enrichment block but an output field whose name strongly signals agent-facing context (notice, effectiveQuery, queryEcho) rather than domain payload.

Exempt: a notice in an output that also declares a sections array — the outline-on-overflow arm (OUTLINE_VARIANT, see the techniques skill). There the notice is the re-call instruction that replaces the document, main-body payload by design, and enrichment can only add to a payload, never replace it.

Fix: move the field into an enrichment block and populate it via ctx.enrich(...) — it reaches both client surfaces without a format() entry. Ignore if the field is genuinely domain data. Deliberately conservative — common domain fields like totalCount are not flagged.

enrichment-trailer-render

Severity: error

Fires when a non-scalar (object/array) enrichment field has no enrichmentTrailer.render. It would JSON.stringify into a one-line blob in the content[] trailer (structuredContent keeps the full value either way). The delta shape (z.object({ before, after }), populated by ctx.enrich.delta()) is exempt — it renders natively as field: before → after.

Fix: add a renderer — enrichmentTrailer: { <field>: { render: (v) => … } } — use ctx.enrich.delta() for before/after state, or opt into the JSON blob explicitly with render: (v) => JSON.stringify(v).

enrichment-trailer-orphan

Severity: error

Fires when enrichmentTrailer is declared without an enrichment block — trailer config only renders enrichment fields.

Fix: add the enrichment block, or drop the enrichmentTrailer.

enrichment-trailer-unknown-field

Severity: error

Fires when an enrichmentTrailer key doesn't match any declared enrichment field (a typo or drift the keyof-typed config already catches for TS authors).

Fix: rename the trailer key to a declared enrichment field, or remove it.

capped-list-no-truncation

Severity: warning

Fires when a tool:

  1. has a depth-0 input field whose name is cap-shaped, AND
  2. has at least one depth-0 array-typed output field, AND
  3. the cap plausibly bounds that list, AND
  4. declares no truncation disclosure.

Cap-shaped means, after normalizing camelCase to snake_case (so maxRecords and max_records are one case):

ShapeExamples
limit, <noun>_limit / <noun>Limitlimit, result_limit, resultLimit
max_<noun> / max<Noun>max_results, maxResults, max_items, maxRecords, maxRows
page-size idiomsper_page, perPage, page_size, pageSize

Matched by shape rather than an enumerated list, so a new cap noun is covered on arrival instead of silently disabling the rule for that tool. Deliberately not matched: bare count, size, n, rows, records, and words that merely begin with the letters (maximum).

The max_ arm is narrowed by what the noun counts. limit, <noun>_limit, and the page-size idioms say what they bound in the name, so they always qualify. max_<noun> does not — the same spelling carries value bounds (max_depth_km, maxLat, max_date, max_magnitude) and budgets on secondary work (max_court_lookups, maxCharacters, max_tokens), none of which slice the array. So the counted noun has to name something the tool returns:

  • it correlates with a depth-0 array in output — plural-insensitive, with a trailing _count stripped first: max_articles → articles, max_result_count → results, maxComments → comments; or
  • it is a generic result container — results, records, items, rows, hits, entries, matches, count, page, docs — which keeps maxRecords firing against an articles array whatever the domain called its list.

Singularization covers only the bounded suffixes above (ies → y, ses/xes/ches/shes, trailing s); it is not a general English pluralizer.

Accepted false negative: a domain cap naming neither an array nor a container — max_studies returning documents — goes silent. Nothing in the declaration separates it from a value bound, and the allowlist only suppresses, so it cannot bring the warning back. Declaring truncated / totalCount is the outcome the rule is chasing anyway.

Disclosure-present (rule silent) when any of the following is true:

  • The declared enrichment shape has a truncated or totalCount key (ctx.enrich.truncated() and ctx.enrich.total() satisfy this).
  • The output schema has a depth-0 truncated or totalCount field.

A silently capped list leaves the agent unaware that results were cut off — it may treat a partial set as complete. Use ctx.enrich.truncated({ shown, cap }) for the one-liner:

ts
// In the enrichment block — optional, since truncated() fires only on a capped page:
enrichment: {
  truncated: z.boolean().optional().describe('True when the list was capped at the limit.'),
  shown: z.number().optional().describe('Number of items returned.'),
  cap: z.number().optional().describe('The limit applied.'),
},

// In the handler:
if (items.length >= input.limit) {
  ctx.enrich.truncated({ shown: items.length, cap: input.limit });
}

Or use ctx.enrich.total(n) when the upstream total is known — that writes totalCount, which is also recognized as honest disclosure.

Threshold bound: when the list is sorted by the cap key and the upstream total is unknowable (e.g. an API returning only the page), the smallest shown value upper-bounds all omitted items. Pass it as ceiling:

ts
ctx.enrich.truncated({ shown: items.length, cap: input.limit, ceiling: items.at(-1)?.count });

Declare truncationCeiling: z.number().optional() in the enrichment block to surface it.

Knob: suppress via LintInput.truncationAllowlist:

ts
// Exempt a specific tool:
validateDefinitions({ tools, truncationAllowlist: ['my_search_tool'] });

// Disable the rule entirely:
validateDefinitions({ tools, truncationAllowlist: false });

Project config: scripts/lint-mcp.ts — the CLI behind bun run lint:mcp and devcheck's MCP Definitions step — reads lint.truncationAllowlist from the project's devcheck.config.json and forwards it as LintInput.truncationAllowlist. One declaration covers every entrypoint that shells out to the linter, and it survives framework sync (the script itself does not — a scaffold's copy is replaced on the next maintenance pass).

json
{
  "lint": {
    "truncationAllowlist": ["my_search_tool"]
  }
}

"truncationAllowlist": false disables the rule, matching the LintInput and env-var forms. The file is parsed with JSON.parse, so the key takes no inline comment; a value that is neither false nor an array of tool names is reported and ignored.

Env var: MCP_LINT_TRUNCATION_ALLOWLIST — comma-separated tool names; the literal false disables.

Precedence: an explicit LintInput.truncationAllowlist wins, then devcheck.config.json, then the env var. A config file that declares no truncationAllowlist passes nothing through, so the env var still applies — the var is the escape hatch for a project that declares nothing, not an override for one that does.


Escape hatches

Dynamic upstream data

If output wraps a third-party API whose shape you can't pin down, prefer z.object({}).passthrough() over aspirational typing. The linter skips format-parity for passthrough schemas, and structuredContent still receives the full payload.

Temporarily suppress a warning

Warnings don't block startup, so you can ship with them logged. If one is genuinely wrong (rather than the rule being wrong for your case), file an issue against @cyanheads/mcp-ts-core with the repro — the linter rules are still maturing.

Escape isn't "make it pass"

Don't remove fields from output to silence format-parity — that makes the data invisible to structuredContent clients too. Don't rename description to something else to silence describe-on-fields. The right fix is either to render the field (format-parity) or accept the warning (description-required).


Adding a new rule

If you're extending @cyanheads/mcp-ts-core with a new lint rule:

  1. Add the rule to src/linter/rules/<family>-rules.ts. Return LintDiagnostic objects with a stable rule ID.
  2. Wire it into validateDefinitions() in src/linter/validate.ts if it's a new family.
  3. Add tests in tests/unit/linter/.
  4. Document the rule in this file. Add it to the rule index, write a section under the matching family, and bump metadata.version in the frontmatter.
  5. The breadcrumb mapping in validateDefinitions() is family-prefix-based (server-json-* → #server-json-rules, etc.), so rules in existing families pick up the right anchor automatically.

© cyanheads, Apache-2.0. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file

Files

Just SKILL.md in framework-skills/api-linter of cyanheads/pubmed-mcp-server.

Open the folder on GitHubat commit 5a417fb

Compare with similar skills

API Linter next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.

API Linter compared with similar skills
SkillStarsUsed inTokensAuto-checkLicenceRepo updated
API Linter this skillcyanheads/pubmed-mcp-server155—~17kAutomated safety check: NotesApache-2.0
Ue Code AuthoringJasonMa0012/MooaToon749—~1.9kAutomated safety check: NotesCustom licence
Rsigmatimescale/rsigma159—~1.2kAutomated safety check: PassMIT
Code Reviewnteract/semiotic2.7k—~1.5kAutomated safety check: PassApache-2.0
Building Glamorous TuisDicklesworthstone/meta_skill205—~3.4kAutomated safety check: PassCustom licence
Cuga Contributor Workflowscuga-project/cuga-agent895—~277Automated safety check: PassCustom licence

Similar skills

  • Ue Code Authoring

    JasonMa0012/MooaToon

    A skill your agent uses when writing or modifying UE C++ (classes, actors, components, subsystems, interfaces, function libraries) with Rider MCP available.

    749 GitHub stars~1.9k tokensUpdated 20 days ago
    DevelopmentAuto-check: notes
  • Rsigma

    timescale/rsigma

    Use the rsigma CLI and MCP server: engine eval, engine daemon, rule lint, rule draft, rule tune, rule backtest, backend convert, mcp serve.

    159 GitHub stars~1.2k tokensUpdated 2 days ago
    DevelopmentAuto-check passed
  • Code Review

    nteract/semiotic

    Review Semiotic pull requests for behavioral bugs, regressions, contract drift, and missing evidence.

    2.7k GitHub stars~1.5k tokensUpdated yesterday
    DevelopmentAuto-check passed
  • Building Glamorous Tuis

    Dicklesworthstone/meta_skill

    Build terminal UIs with Charmbracelet (Bubble Tea, Lip Gloss, Gum).

    205 GitHub stars~3.4k tokensUpdated 3 days ago
    DevelopmentAuto-check passed
  • Cuga Contributor Workflows

    cuga-project/cuga-agent

    Run CUGA contributor workflows: Conventional Commits, create pull requests against origin with gh and PR templates, and ruff check/format via uv.

    895 GitHub stars~277 tokensUpdated today
    DevelopmentAuto-check passed
  • Zhtw Verify

    sysprog21/zhtw-mcp

    How a zhtw-mcp change is validated - make check as the gate, the generated tables and the ruleset normalization that have to be current before it passes, the formatter chain in scripts/indent.sh…

    488 GitHub stars~2.8k tokensUpdated 3 days ago
    DevelopmentAuto-check passed

More from cyanheads/pubmed-mcp-server

All 30 skills in this repo
  • Add App Tool

    cyanheads/pubmed-mcp-server

    Scaffold an MCP App tool + UI resource pair. An agent skill from cyanheads/pubmed-mcp-server.

    155 GitHub stars~3.2k tokensUpdated 4 days ago
    Auto-check passed
  • Add Prompt

    cyanheads/pubmed-mcp-server

    Scaffold a new MCP prompt template. An agent skill from cyanheads/pubmed-mcp-server.

    155 GitHub stars~1.6k tokensUpdated 4 days ago
    Auto-check passed
  • Add Resource

    cyanheads/pubmed-mcp-server

    Scaffold a new MCP resource definition. An agent skill from cyanheads/pubmed-mcp-server.

    155 GitHub stars~3k tokensUpdated 4 days ago
    Auto-check passed
  • Add Service

    cyanheads/pubmed-mcp-server

    Scaffold a new service integration. An agent skill from cyanheads/pubmed-mcp-server.

    155 GitHub stars~3.6k tokensUpdated 4 days ago
    Auto-check passed
  • Add Test

    cyanheads/pubmed-mcp-server

    Scaffold a test file for an existing tool, resource, or service.

    155 GitHub stars~4.1k tokensUpdated 4 days ago
    Auto-check passed
  • API Auth

    cyanheads/pubmed-mcp-server

    Authentication, authorization, and multi-tenancy patterns for @cyanheads/mcp-ts-core.

    155 GitHub stars~2.7k tokensUpdated 4 days ago
    Auto-check passed

Categories

Questions about API Linter

What does API Linter do?

MCP definition linter rules reference. An agent skill from cyanheads/pubmed-mcp-server. API Linter is an agent skill from cyanheads/pubmed-mcp-server. MCP definition linter rules reference.

When should I use API Linter?

API Linter fits situations like: bun run lint:mcp; bun run devcheck reports a lint error; warning (format-parity; schema-is-object.

How do I install API Linter in Claude Code?

Run `npx skills add cyanheads/pubmed-mcp-server --skill api-linter -a claude-code`. Or copy the skill folder (framework-skills/api-linter in cyanheads/pubmed-mcp-server) into .claude/skills/api-linter in your project. Claude Code loads it when a task matches its description.

How do I install API Linter in Codex?

Run `npx skills add cyanheads/pubmed-mcp-server --skill api-linter -a codex`. Or copy the skill folder (framework-skills/api-linter in cyanheads/pubmed-mcp-server) into .agents/skills/api-linter in your project. Codex loads it when a task matches its description.

Can I use API Linter in Cursor, Gemini CLI or GitHub Copilot?

Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add cyanheads/pubmed-mcp-server --skill api-linter -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/api-linter, .gemini/skills/api-linter, .github/skills/api-linter and .opencode/skills/api-linter in your project.

What does API Linter need to run?

Going by SKILL.md and its folder, API Linter needs the command-line tools its instructions call (bun).

Does API Linter access the network?

SKILL.md names 3 domains. In commands or code: json-schema.org; the agent is likely to contact it when it follows the instructions. As links in the text: github.com and modelcontextprotocol.io. This is read from the text; nothing was executed.

Is API Linter safe to install?

Our automated static check of SKILL.md found notes only (mentions a .env file), nothing it rates as a warning. It is not a guarantee. Review the folder before installing.

What licence does API Linter use?

API Linter is published under the Apache-2.0 licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.

How many tokens does API Linter use?

About 17k tokens (SKILL.md is roughly 70k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.

What are the alternatives to API Linter?

Skills that share tags, products or a category with API Linter: Ue Code Authoring (JasonMa0012/MooaToon, 749 stars), Rsigma (timescale/rsigma, 159 stars), Code Review (nteract/semiotic, 2.7k stars) and Building Glamorous Tuis (Dicklesworthstone/meta_skill, 205 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.

Who maintains API Linter?

cyanheads (a GitHub user) maintains it in cyanheads/pubmed-mcp-server, which has 155 GitHub stars. The repository holds 30 skills in this directory. The repository was last updated on October 4, 2026.

Source: cyanheads/pubmed-mcp-server on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.