agentleFS
ficaviolaodorata520ficaviolaodorata520/met-museum-mcp-server/skills/api-linter/SKILL.md

api-linter

Skill0 starsChanged 6 months agoApache-2.0View on GitHub

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.

The file

Show its rules and text

We split this skill into the rules it gives an agent

29 rules. About 0 are common, 5 are distinctive, and 24 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈5

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 24

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    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 …

    line 85
  2. Distinctive:

    Add narrow guards. The linter feeds a synthetic but schema-valid value.

    line 107
  3. Very specific:

    Every field in input, output, params, or args needs a .describe('...') call.

    line 140
  4. Very specific:

    Fix: add .describe('...') to the paths the linter flags.

    line 142
  5. Distinctive:

    Fix: use structural equivalents.

    line 179
  6. Very specific:

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

    line 239
  7. Very specific:

    Every tool, resource, and prompt definition needs a non-empty name string.

    line 271
  8. Very specific:

    Every tool, resource, and prompt needs a non-empty description.

    line 299
  9. Distinctive:

    Fix: write a single cohesive paragraph.

    line 303
  10. Very specific:

    Every tool must have a handler function (or taskHandlers object for task tools).

    line 309
  11. Very specific:

    Every element in auth must be a non-empty string.

    line 330
  12. Very specific:

    When a tool declares _meta.ui, that field must be an object.

    line 348
  13. Very specific:

    Every resource needs a non-empty uriTemplate string.

    line 378
  14. Very specific:

    Fix: add a short name field:

    line 392
  15. Very specific:

    Every variable in the URI template must appear as a key in the params schema.

    line 405
  16. Very specific:

    Every prompt needs a generate function that returns the message array.

    line 417
  17. Very specific:

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

    line 520
  18. Very specific:

    When a contract is declared, the handler receives a typed ctx.fail(reason, …) keyed by the declared reason union.

    line 581
  19. Distinctive:

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

    line 671
  20. Very specific:

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

    line 689
  21. Very specific:

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

    line 738
  22. Very specific:

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

    line 762
  23. Very specific:

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

    line 770
  24. Very specific:

    If output wraps a third-party API whose shape you can't pin down, prefer z.object({}).passthrough() over aspirational typing.

    line 786
  25. Very specific:

    Don't remove fields from output to silence format-parity — that makes the data invisible to structuredContent clients too.

    line 794
  26. Very specific:

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

    line 800
  27. Very specific:

    Add the rule to src/linter/rules/<family>-rules.ts.

    line 802
  28. Very specific:

    Add tests in tests/unit/linter/.

    line 804
  29. Very specific:

    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 …

    line 805

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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 CIPrints errors + warnings, exits non-zero on errors.
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). Each diagnostic has a stable rule ID — that's the anchor you land on via the See: skills/api-linter/SKILL.md#<rule> breadcrumb appended to every message.

Severity:

  • error — MUST-level spec violation; blocks devcheck.
  • warning — SHOULD-level or quality issue; logged but devcheck continues.

Imports (if you need to run the linter programmatically):

import { validateDefinitions } from '@cyanheads/mcp-ts-core/linter';
import type { LintReport, LintDiagnostic } from '@cyanheads/mcp-ts-core/linter';

const report = validateDefinitions({ tools, resources, prompts, serverJson, packageJson });
if (!report.passed) process.exit(1);

Rule index

Grouped by family. Jump to any rule ID via its anchor.

FamilyRulesSection
Format parityformat-parity, format-parity-threw, format-parity-walk-failedFormat parity
Schemaschema-is-object, describe-on-fields, schema-serializableSchema rules
Portabilityschema-format-portability, schema-anyof-needs-type, schema-no-discriminator-keyword, schema-no-defs, schema-dialect-tagPortability rules
Namesname-required, name-format, name-uniqueName rules
Toolsdescription-required, handler-required, auth-type, auth-scope-format, annotation-type, annotation-coherence, meta-ui-type, meta-ui-resource-uri-required, meta-ui-resource-uri-scheme, app-tool-resource-pairingTool 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-recovery-required, error-contract-recovery-empty, error-contract-recovery-min-wordsError contract rules
Error contract (conformance)error-contract-conformance, error-contract-prefer-failError contract rules
Enrichmentenrichment-type, enrichment-empty, enrichment-field-type, enrichment-output-collision, enrichment-prefer-block, enrichment-trailer-render, enrichment-trailer-orphan, enrichment-trailer-unknown-fieldEnrichment rules
server.json~40 rules prefixed server-json-*server.json rules

Format parity

Why this family exists: different MCP clients forward different surfaces of a tool response to the model. Claude Code reads structuredContent (from your handler's return value, typed by output). Claude Desktop reads content[] (from your format() function). Every field must be visible on both surfaces or one class of client sees less than another. The linter enforces this by synthesizing a sample value where every leaf is a uniquely identifiable sentinel, calling format() once, then verifying each sentinel (or its key name, for permissive types like booleans) appears in the rendered text.

format-parity

Severity: error

Fires when format() does not render a field present in output. Emitted once per missing field; large schemas can produce many format-parity diagnostics from a single tool.

Primary fix: render the missing field in format(). For tools that return either a summary list or a detail view, use z.discriminatedUnion so each branch is walked separately:

output: z.discriminatedUnion('mode', [
  z.object({ mode: z.literal('list'), items: z.array(ItemSchema) }),
  z.object({ mode: z.literal('detail'), item: ItemSchema, history: z.array(HistoryEntry) }),
]),

format: (result) => {
  if (result.mode === 'list') return renderList(result.items);
  return renderDetail(result.item, result.history);
}

Escape hatch: if the output schema was over-typed for a genuinely dynamic upstream API (e.g., a third-party JSON blob whose shape you can't nail down), relax it:

output: z.object({}).passthrough()

passthrough() still flows the full payload to structuredContent without declaring each field, so the linter has nothing to check against and you're not maintaining aspirational typing.

Anti-pattern: summary-only format() like return [{ type: 'text', text: \Found ${n} items` }]. The sentinel walk will flag every field in the items array. Don't "fix" this by removing fields from output— that makesstructuredContent` clients blind too.

format-parity-threw

Severity: warning

Fires when format() throws while being called with a synthetic sample. The linter cannot verify parity because your formatter crashed before producing output.

Fix: format() must be total — render any valid value of the output schema without throwing. Common causes:

  • Assuming an optional array is always present (result.items.map(...) when items could be undefined)
  • Dereferencing a discriminated-union branch without checking the discriminator
  • Calling toFixed() or toISOString() on a value that could legitimately be any number/string

Add narrow guards. The linter feeds a synthetic but schema-valid value; if your formatter can't handle it, real inputs will eventually hit the same path.

format-parity-walk-failed

Severity: warning

Fires when the linter cannot walk the output schema to build a synthetic sample (usually because the schema uses an unusual composition the walker doesn't recognize). Parity is not verified for that tool — nothing is broken at runtime, but the check is silently disabled.

Fix: inspect the walker error message in the diagnostic. Usually caused by very deep recursion, custom Zod extensions, or mixing Zod 3 and 4 schema internals. File an issue against @cyanheads/mcp-ts-core with the schema shape — this is a linter gap, not user error.


Schema rules

schema-is-object

Severity: error

Tool input/output and prompt args must be z.object({...}) at the top level (not z.string(), z.array(...), etc.). The MCP spec requires a keyed structure at the schema root.

Fix: wrap whatever you had in a single-key object:

// Wrong
input: z.array(z.string())
// Right
input: z.object({ items: z.array(z.string()).describe('List of items') })

describe-on-fields

Severity: warning

Every field in input, output, params, or args needs a .describe('...') call. Descriptions ship to the client and the LLM — missing ones make tools harder to use correctly.

Fix: add .describe('...') to the paths the linter flags. The diagnostic names which path is missing a description (e.g., input.filters.status).

Recursion rules — the linter walks selectively; primitive array elements are intentionally skipped. Knowing what's walked prevents over-application of describes that end up as noise in the generated JSON Schema.

Schema 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

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.

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:

// Wrong
z.date()
// Right
z.string().describe('ISO 8601 timestamp, e.g., 2026-04-20T12:00:00Z')

Parse the string to a Date inside the handler if you need one.


Portability rules

MCP pins JSON Schema 2020-12 as the default dialect (SEP-1613), but LLM vendors accept different subsets. A schema that passes schema-serializable can still hard-fail at OpenAI's tool validator or silently lose fields at Gemini's API surface. These rules walk the emitted JSON Schema for patterns that break cross-vendor.

Three default-on, two opt-in. Promote opt-ins via MCP_LINT_PORTABILITY=strict (env) or validateDefinitions({ portability: 'strict' }) when targeting multi-vendor deployments.

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:

// 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 });

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-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.

// 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.

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.


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:

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.)


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-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. 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(...). Plain Error doesn't carry a JSON-RPC code — the framework's auto-classifier degrades to InternalError, hiding the actual failure mode.

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 });

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:

// 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.

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 });
}

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.

// 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 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:

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-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 — it flows to the wire via ctx.recoveryFor.

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 JsonRpcErrorCode.X or a factory like notFound()) that isn't declared in errors[].

Baseline codes (InternalError, ServiceUnavailable, Timeout, ValidationError, SerializationError) 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 catches direct throw new McpError(JsonRpcErrorCode.X, …) and throw factory(…) patterns. 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:

// 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.


Enrichment rules

Validate the enrichment block — the success-path counterpart to errors[]. Enrichment fields are merged into structuredContent and advertised as output.extend(enrichment), 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.

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.


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.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.