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.
MCP definition linter rules reference. An agent skill from cyanheads/pubmed-mcp-server.
$ npx skills add cyanheads/pubmed-mcp-server --skill api-linter -a claude-codeProject install by default; add -g for ~/.claude/skills/.
$ gh skill install cyanheads/pubmed-mcp-server api-linter --agent claude-codeProject scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).
$ 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-srcUse ~/.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/
Install the "api-linter" agent skill from https://github.com/cyanheads/pubmed-mcp-server/tree/main/framework-skills/api-linter into .claude/skills/api-linter/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "api-linter", then confirm the skill loads.Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$skill-installer install https://github.com/cyanheads/pubmed-mcp-server/tree/main/framework-skills/api-linterType this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
$ npx skills add cyanheads/pubmed-mcp-server --skill api-linter -a codexProject install goes to .agents/skills/; add -g for ~/.codex/skills/.
$ gh skill install cyanheads/pubmed-mcp-server api-linter --agent codexProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/cyanheads/pubmed-mcp-server.git skills-src && mkdir -p .agents/skills && cp -r skills-src/framework-skills/api-linter .agents/skills/api-linter && rm -rf skills-srcUse ~/.agents/skills/ instead of .agents/skills for a personal install.
Codex skills documentation · loads skills from .agents/skills/
Install the "api-linter" agent skill from https://github.com/cyanheads/pubmed-mcp-server/tree/main/framework-skills/api-linter into .agents/skills/api-linter/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "api-linter", then confirm the skill loads.Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add cyanheads/pubmed-mcp-server --skill api-linter -a cursorProject install goes to .agents/skills/; add -g for ~/.cursor/skills/.
$ gh skill install cyanheads/pubmed-mcp-server api-linter --agent cursorProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/cyanheads/pubmed-mcp-server.git skills-src && mkdir -p .cursor/skills && cp -r skills-src/framework-skills/api-linter .cursor/skills/api-linter && rm -rf skills-srcUse ~/.cursor/skills/ instead of .cursor/skills for a personal install.
Cursor skills documentation · loads skills from .cursor/skills/, .agents/skills/, .claude/skills/, .codex/skills/
Install the "api-linter" agent skill from https://github.com/cyanheads/pubmed-mcp-server/tree/main/framework-skills/api-linter into .cursor/skills/api-linter/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "api-linter", then confirm the skill loads.Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gemini skills install https://github.com/cyanheads/pubmed-mcp-server.git --path framework-skills/api-linter--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
$ npx skills add cyanheads/pubmed-mcp-server --skill api-linter -a gemini-cliProject install goes to .agents/skills/; add -g for ~/.gemini/skills/.
$ gh skill install cyanheads/pubmed-mcp-server api-linter --agent gemini-cliProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/cyanheads/pubmed-mcp-server.git skills-src && mkdir -p .gemini/skills && cp -r skills-src/framework-skills/api-linter .gemini/skills/api-linter && rm -rf skills-srcUse ~/.gemini/skills/ instead of .gemini/skills for a personal install, then run /skills reload.
Gemini CLI skills documentation · loads skills from .gemini/skills/, .agents/skills/
Install the "api-linter" agent skill from https://github.com/cyanheads/pubmed-mcp-server/tree/main/framework-skills/api-linter into .gemini/skills/api-linter/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "api-linter", then confirm the skill loads.Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gh skill install cyanheads/pubmed-mcp-server api-linterInstalls for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
$ npx skills add cyanheads/pubmed-mcp-server --skill api-linter -a github-copilotProject install goes to .agents/skills/; add -g for ~/.copilot/skills/.
$ git clone --depth 1 https://github.com/cyanheads/pubmed-mcp-server.git skills-src && mkdir -p .github/skills && cp -r skills-src/framework-skills/api-linter .github/skills/api-linter && rm -rf skills-srcUse ~/.copilot/skills/ instead of .github/skills for a personal install. Commit .github/skills so cloud agent and code review can use it.
GitHub Copilot skills documentation · loads skills from .github/skills/, .claude/skills/, .agents/skills/
Install the "api-linter" agent skill from https://github.com/cyanheads/pubmed-mcp-server/tree/main/framework-skills/api-linter into .github/skills/api-linter/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "api-linter", then confirm the skill loads.GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add cyanheads/pubmed-mcp-server --skill api-linter -a opencodeOpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
$ gh skill install cyanheads/pubmed-mcp-server api-linter --agent opencodeProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/cyanheads/pubmed-mcp-server.git skills-src && mkdir -p .opencode/skills && cp -r skills-src/framework-skills/api-linter .opencode/skills/api-linter && rm -rf skills-srcUse ~/.config/opencode/skills/ instead of .opencode/skills for a personal install.
OpenCode skills documentation · loads skills from .opencode/skills/, .claude/skills/, .agents/skills/
Install the "api-linter" agent skill from https://github.com/cyanheads/pubmed-mcp-server/tree/main/framework-skills/api-linter into .opencode/skills/api-linter/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "api-linter", then confirm the skill loads.OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
api-linterMCP 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. 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.
4 steps, taken from the first numbered list in SKILL.md.
Read from SKILL.md and the folder at commit 5a417fb. It shows what the files ask for, not the result of running them.
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.
Shell commands in SKILL.md call:
bunFrom the folder's file list and the shell code blocks in SKILL.md.
Hosts in commands or code, which the agent is likely to contact:
json-schema.orgAlso links to:
github.commodelcontextprotocol.ioFrom URLs in SKILL.md, links to its own repository left out.
Names no API keys, tokens, secrets or passwords.
From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
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.
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.
The automated check noted patterns worth knowing about, such as sudo or a known installer.
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.
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.
.claude/skills/api-linter/SKILL.md (or your agent's skills folder).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 | Imports 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 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), 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:
devcheck.devcheck continues.Imports (if you need to run the linter programmatically):
import { validateDefinitions } from '@cyanheads/mcp-ts-core/linter';
import type { LintReport, LintDiagnostic } from '@cyanheads/mcp-ts-core/linter';
const report = validateDefinitions({ tools, resources, prompts, serverJson, packageJson });
if (!report.passed) process.exit(1);Grouped by family. Jump to any rule ID via its anchor.
| Family | Rules | Section |
|---|---|---|
| Definition | definition-invalid, definition-import-failed | Definition rules |
| Format parity | format-parity, format-parity-threw, format-parity-walk-failed, format-parity-depth-limit | Format parity |
| Schema | schema-is-object, describe-on-fields, schema-serializable, schema-unsatisfiable, header-param-designation, schema-root-meta-discarded | Schema rules |
| Portability | schema-format-portability, schema-anyof-needs-type, schema-no-discriminator-keyword, schema-no-defs, schema-root-oneof-portability, schema-dialect-tag | Portability rules |
| Names | name-required, name-format, name-unique | Name rules |
| Tools | description-required, handler-required, auth-type, auth-scope-format, annotation-type, annotation-coherence, input-alias-conflict, meta-ui-type, meta-ui-resource-uri-required, meta-ui-resource-uri-scheme, app-tool-resource-pairing, canvas-consumer-missing | Tool rules |
| Resources | uri-template-required, uri-template-valid, resource-name-not-uri, template-params-align | Resource rules |
| Landing | landing-* (23 rules — shape, tagline, logo, links, repo, envExample, connectSnippets, theme) | Landing config rules |
| Prompts | generate-required | Prompt rules |
| Handler body | prefer-mcp-error-in-handler, prefer-error-factory, preserve-cause-on-rethrow, no-stringify-upstream-error | Handler body rules |
| Error contract (structural) | error-contract-type, error-contract-empty, error-contract-entry-type, error-contract-code-type, error-contract-code-unknown, error-contract-code-unknown-error, error-contract-reason-required, error-contract-reason-format, error-contract-reason-unique, error-contract-when-required, error-contract-retryable-type, error-contract-severity-unknown, error-contract-recovery-required, error-contract-recovery-empty, error-contract-recovery-min-words | Error contract rules |
| Error contract (conformance) | error-contract-conformance, error-contract-prefer-fail, error-contract-unthrown | Error contract rules |
| Enrichment | enrichment-type, enrichment-empty, enrichment-field-type, enrichment-output-collision, enrichment-prefer-block, enrichment-trailer-render, enrichment-trailer-orphan, enrichment-trailer-unknown-field, capped-list-no-truncation | Enrichment rules |
| server.json | ~40 rules prefixed server-json-* | server.json rules |
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)).
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.
✗ [definition-import-failed] src/mcp-server/tools/definitions/query.tool.ts: Cannot find package '@duckdb/node-api' imported from …Fix, by cause:
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.setup(), a service's init, or the handler. Definitions must import without side effects.devcheck's typecheck reports the same file with a location.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.Why this family exists: different MCP clients forward different surfaces of a tool response to the model. Claude Code reads structuredContent (from your handler's return value, typed by output). Claude Desktop reads content[] (from your format() function). Every field must be visible on both surfaces or one class of client sees less than another. The linter enforces this by synthesizing a sample value where every leaf is a uniquely identifiable sentinel, calling format() once, then verifying each sentinel (or its key name, for permissive types like booleans) appears in the rendered text.
How leaves are matched. Two strategies, picked by leaf type:
| Leaf type | Sentinel | Match |
|---|---|---|
| string | MCPPARITY<path> — alphanumeric only | substring, anywhere in the rendered text |
| number / int / bigint | a large distinctive integer | substring, retried against locale digit grouping (900,000,001 → 900000001) |
| boolean, enum member, literal, unrecognized type | the value the schema dictates (true, the first enum member, the literal) | delimited token — must not be flanked by another alphanumeric or _; falls back to the field's key name as a whole word or camelCase segment |
Two consequences worth knowing when writing a format():
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.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.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.
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:
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.
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:
result.items.map(...) when items could be undefined)toFixed() or toISOString() on a value that could legitimately be any number/stringAdd 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.
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.
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).
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:
// 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>.
Severity: warning
Every field in input, output, params, or args needs a .describe('...') call. Descriptions ship to the client and the LLM — missing ones make tools harder to use correctly.
Fix: add .describe('...') to the paths the linter flags. The diagnostic names which path is missing a description (e.g., input.filters.status).
Recursion rules — the linter walks selectively; primitive array elements are intentionally skipped. Knowing what's walked prevents over-application of describes that end up as noise in the generated JSON Schema.
| Schema position | Walked? | Describe required on inner? |
|---|---|---|
z.object({ ... }) field | Yes | Yes, on each field |
z.array(compound) element — object, array, or union | Yes | Yes, on the element |
z.array(primitive) element — string, number, enum, regex-branded primitive, etc. | No | No — outer array describe is sufficient |
z.union([a, b, ...]) non-literal option | Yes | Yes, on each option |
z.union([..., z.literal(X), ...]) literal option | No | No — outer union describe is sufficient |
A tool input root that is a z.discriminatedUnion(...) — its variant objects | Yes, their fields | No, not on the variant itself — it is a root, and roots carry no describe |
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:
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.
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:
// 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.
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 wrote | What 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.
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.
| Constraint | Example violation |
|---|---|
Statically reachable through a chain of properties keys | A 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, boolean | headerParam(z.object({ … }), 'Region') |
| Non-empty RFC 9110 token | headerParam(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.
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:
z.object({ … }).describe('An object root.') // lost — tool() strictens after
z.object({ … }).strict().describe('An object root.') // kept — already strict, returned untouchedThe 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.
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' |
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 call | Emitted format | Allowed? |
|---|---|---|
z.email(), z.uuid(), z.iso.datetime(), z.iso.date() | email / uuid / date-time / date | yes |
z.url() | uri | no — fires |
z.cuid(), z.cuid2(), z.ulid(), z.nanoid(), z.base64(), z.jwt() | various | no — fires |
Fix: drop the format method, move the constraint into .describe() text where the model reads it:
// 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:
validateDefinitions({ formatAllowlist: ['email', 'uuid', 'date-time', 'uri'], tools, resources, prompts });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".
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.
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.
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.
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).
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).
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.
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.
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.
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).
Severity: error
auth must be an array of strings. A single string or other shape is rejected.
// Wrong
auth: 'tool:my_tool:read'
// Right
auth: ['tool:my_tool:read']Severity: error
Every element in auth must be a non-empty string. Empty strings in the array are rejected — they'd match anything.
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.
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.
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:
| Condition | Example |
|---|---|
| An alias must not equal a declared key | input: 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 key | inputAliases: { q: 'searchQuery' } when the schema declares query |
An alias's target must not be headerParam-designated | inputAliases: { 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 name | z.object({ maxResults, max_results }) — no alias can resolve between them |
| An alias must not case-fold to a declared key other than its target | inputAliases: { max_results: 'query' } alongside a declared maxResults |
| Two aliases must not case-fold to one name with different targets | inputAliases: { '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.
Severity: error (MCP Apps tools only)
When a tool declares _meta.ui, that field must be an object. null, arrays, or primitives are rejected.
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.
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.
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.
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:
<prefix>_dataframe_query and <prefix>_dataframe_describe consumers (see api-canvas).Knob: suppress via LintInput.canvasConsumers:
// 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.
Severity: error
Every resource needs a non-empty uriTemplate string. The URI template is the resource's primary identifier.
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.
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:
resource('myscheme://{id}/data', {
name: 'Item data', // <-- add this
// ...
})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.
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.)
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 ID | Severity | What it checks |
|---|---|---|
server-json-parse | error | server.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-type | error | server.json must be a JSON object, not an array or primitive |
server-json-name-required | error | name must be present and non-empty |
server-json-name-length | error | name length 3–200 characters |
server-json-name-format | error | name must match reverse-DNS pattern owner/project |
server-json-description-required | error | description must be present and non-empty |
server-json-description-length | warning | description > 100 chars — some registries truncate |
server-json-version-required | error | version must be present |
server-json-version-length | error | version length ≤ 255 |
server-json-version-no-range | error | version must be a specific version, not a range (^, ~, >=, etc.) |
server-json-version-semver | warning | version should be valid semver (major.minor.patch) |
server-json-version-sync | warning | server.json version should match package.json version |
server-json-repository-type | error | repository must be an object |
server-json-repository-url | error | repository.url is required when repository is present |
server-json-repository-source | error | repository.source is required when repository is present |
server-json-packages-type | error | packages must be an array |
server-json-package-type | error | Each packages[i] must be an object |
server-json-package-registry | error | packages[i].registryType is required |
server-json-package-identifier | error | packages[i].identifier is required |
server-json-package-transport | error | packages[i].transport is required |
server-json-package-no-latest | error | packages[i].version must not be "latest" — pin a specific version |
server-json-package-version-sync | warning | packages[i].version should match root version |
server-json-package-args-type | error | packages[i].packageArguments must be an array |
server-json-runtime-args-type | error | packages[i].runtimeArguments must be an array |
server-json-env-vars-type | error | packages[i].environmentVariables must be an array |
server-json-remotes-type | error | remotes must be an array |
server-json-remote-type | error | Each remotes[i] must be an object |
server-json-remote-transport-type | error | remotes[i].type is required |
server-json-remote-no-stdio | error | remotes[i].type must be streamable-http or sse — stdio is not valid for remotes |
server-json-transport-type | error | transport must be an object |
server-json-transport-type-value | error | transport.type must be one of stdio, streamable-http, sse |
server-json-transport-url-required | error | transport.url required for streamable-http and sse |
server-json-transport-url-format | warning | transport.url should be http:// or https:// |
server-json-argument-type | error | Each argument must be an object |
server-json-argument-type-value | error | argument.type must be positional or named |
server-json-argument-name | error | Named arguments require name |
server-json-argument-value | error | Positional arguments require value or valueHint |
server-json-input-format | warning | format should be string, number, boolean, or filepath |
server-json-env-var-type | error | Each environment variable must be an object |
server-json-env-var-name | error | Environment variable name is required |
server-json-env-var-description | warning | Environment 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.
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.
| Rule | Severity | Catches |
|---|---|---|
landing-shape | error | landing is not a plain object |
landing-tagline-type | error | tagline is present but not a string |
landing-tagline-length | error | tagline exceeds the max length |
landing-logo-type | error | logo is present but not a string |
landing-logo-size | error | logo is too long for inline rendering |
landing-links-type | error | links is present but not an array |
landing-links-count | error | links exceeds the max count |
landing-link-shape | error | A links[] entry is not a plain object |
landing-link-href | error | A link entry's href is missing or not a non-empty string |
landing-link-label | error | A link entry's label is missing or not a non-empty string |
landing-repo-root-type | error | repoRoot is present but not a string |
landing-repo-root-shape | error | repoRoot is not a recognized GitHub URL shape |
landing-env-example-type | error | envExample is present but not a plain object |
landing-env-example-count | error | envExample has too many entries |
landing-env-example-key | error | An envExample key is empty or invalid |
landing-env-example-value | error | An envExample value is not a string |
landing-connect-snippets-type | error | connectSnippets is present but not a plain object |
landing-connect-snippets-key | error | A connectSnippets key is empty |
landing-connect-snippets-value | error | A connectSnippets value is not a string |
landing-connect-snippets-empty | error | A connectSnippets value is an empty string |
landing-theme-type | error | theme is present but not a plain object |
landing-theme-accent | error | theme.accent is present but not a string |
landing-theme-accent-format | error | theme.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.
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.
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:
// instead of:
throw new Error('Item not found');
// use:
throw notFound('Item not found', { itemId });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:
// instead of:
throw new McpError(JsonRpcErrorCode.NotFound, 'Item missing');
// use:
throw notFound('Item missing');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:
try {
await fetchUpstream();
} catch (e) {
throw serviceUnavailable('Upstream failed', { service: 'pubmed' }, { cause: e });
}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.
// instead of:
throw new Error(`Upstream failed: ${JSON.stringify(e)}`);
// use:
throw serviceUnavailable('Upstream failed', { upstreamError: e }, { cause: e });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.
Severity: error
Fires when errors is present but not an array. The contract must be a tuple of ErrorContract entries.
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.
Severity: error
Fires when an entry in errors[] isn't an object. Each entry must be { code, reason, when, recovery } (and optionally retryable).
Severity: error
Fires when an entry's code is missing or not a number. Use the JsonRpcErrorCode enum:
errors: [{ code: JsonRpcErrorCode.NotFound, reason: 'no_match', when: 'No items matched' }]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.
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.
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.
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', …).
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.
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.
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.
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.
// instead of:
{ reason: 'consent_declined', code: JsonRpcErrorCode.InvalidRequest, when: '…', severity: 'warn', recovery: '…' }
// use:
{ reason: 'consent_declined', code: JsonRpcErrorCode.InvalidRequest, when: '…', severity: 'warning', recovery: '…' }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.
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).
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.
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.
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:
// 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.
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.
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:
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.
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.
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>, … }.
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.
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.
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.
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.
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).
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.
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.
Severity: warning
Fires when a tool:
output field, ANDCap-shaped means, after normalizing camelCase to snake_case (so maxRecords and max_records are one case):
| Shape | Examples |
|---|---|
limit, <noun>_limit / <noun>Limit | limit, result_limit, resultLimit |
max_<noun> / max<Noun> | max_results, maxResults, max_items, maxRecords, maxRows |
| page-size idioms | per_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:
output — plural-insensitive, with a trailing _count stripped first: max_articles → articles, max_result_count → results, maxComments → comments; orresults, 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:
enrichment shape has a truncated or totalCount key (ctx.enrich.truncated() and ctx.enrich.total() satisfy this).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:
// 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:
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:
// 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).
{
"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.
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.
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.
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).
If you're extending @cyanheads/mcp-ts-core with a new lint rule:
src/linter/rules/<family>-rules.ts. Return LintDiagnostic objects with a stable rule ID.validateDefinitions() in src/linter/validate.ts if it's a new family.tests/unit/linter/.metadata.version in the frontmatter.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
Just SKILL.md in framework-skills/api-linter of cyanheads/pubmed-mcp-server.
Open the folder on GitHubat commit 5a417fb
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.
| Skill | Stars | Used in | Tokens | Auto-check | Licence | Repo updated |
|---|---|---|---|---|---|---|
| API Linter this skillcyanheads/pubmed-mcp-server | 155 | — | ~17k | Automated safety check: Notes | Apache-2.0 | |
| Ue Code AuthoringJasonMa0012/MooaToon | 749 | — | ~1.9k | Automated safety check: Notes | Custom licence | |
| Rsigmatimescale/rsigma | 159 | — | ~1.2k | Automated safety check: Pass | MIT | |
| Code Reviewnteract/semiotic | 2.7k | — | ~1.5k | Automated safety check: Pass | Apache-2.0 | |
| Building Glamorous TuisDicklesworthstone/meta_skill | 205 | — | ~3.4k | Automated safety check: Pass | Custom licence | |
| Cuga Contributor Workflowscuga-project/cuga-agent | 895 | — | ~277 | Automated safety check: Pass | Custom licence |
JasonMa0012/MooaToon
A skill your agent uses when writing or modifying UE C++ (classes, actors, components, subsystems, interfaces, function libraries) with Rider MCP available.
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.
nteract/semiotic
Review Semiotic pull requests for behavioral bugs, regressions, contract drift, and missing evidence.
Dicklesworthstone/meta_skill
Build terminal UIs with Charmbracelet (Bubble Tea, Lip Gloss, Gum).
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.
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…
cyanheads/pubmed-mcp-server
Scaffold an MCP App tool + UI resource pair. An agent skill from cyanheads/pubmed-mcp-server.
cyanheads/pubmed-mcp-server
Scaffold a new MCP prompt template. An agent skill from cyanheads/pubmed-mcp-server.
cyanheads/pubmed-mcp-server
Scaffold a new MCP resource definition. An agent skill from cyanheads/pubmed-mcp-server.
cyanheads/pubmed-mcp-server
Scaffold a new service integration. An agent skill from cyanheads/pubmed-mcp-server.
cyanheads/pubmed-mcp-server
Scaffold a test file for an existing tool, resource, or service.
cyanheads/pubmed-mcp-server
Authentication, authorization, and multi-tenancy patterns for @cyanheads/mcp-ts-core.
Works with
Categories
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.
API Linter fits situations like: bun run lint:mcp; bun run devcheck reports a lint error; warning (format-parity; schema-is-object.
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.
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.
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.
Going by SKILL.md and its folder, API Linter needs the command-line tools its instructions call (bun).
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.
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.
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.
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.
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.
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.