@cyanheads/mcp-ts-core 0.5.3 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -14,7 +14,7 @@
14
14
  - **Unified Context.** Handlers receive `ctx` with logging (`ctx.log`), tenant-scoped storage (`ctx.state`), optional protocol capabilities (`ctx.elicit`, `ctx.sample`), and cancellation (`ctx.signal`).
15
15
  - **Decoupled storage.** `ctx.state` for tenant-scoped KV. Never access persistence backends directly.
16
16
  - **Runtime parity.** All features work with `stdio`/`http` and Worker bundle. Guard non-portable deps via `runtimeCaps` from `@cyanheads/mcp-ts-core/utils` — a frozen capability object (`isNode`, `isBun`, `isWorkerLike`, `hasBuffer`, `hasProcess`, etc.) computed once at module load. Prefer runtime-agnostic abstractions (Hono + `@hono/mcp`, Fetch APIs).
17
- - **Startup validation.** `createApp()` runs the definition linter before proceeding — errors (spec violations) throw `ConfigurationError` and block startup; warnings are logged. Also available standalone via `bun run lint:mcp` and as a devcheck step.
17
+ - **Startup validation.** `createApp()` runs the definition linter before proceeding — errors (spec violations) throw `ConfigurationError` and block startup; warnings are logged. Also available standalone via `bun run lint:mcp` and as a devcheck step. Every diagnostic links to the rule reference in `api-linter` skill; see that skill for the full rule catalog.
18
18
  - **Elicitation for missing input.** Use `ctx.elicit` when the client supports it.
19
19
 
20
20
  ---
@@ -453,6 +453,7 @@ Detailed method signatures, options, and examples live in skill files. Read the
453
453
  | `api-config` | `skills/api-config/SKILL.md` | AppConfig, parseConfig, env vars |
454
454
  | `api-testing` | `skills/api-testing/SKILL.md` | createMockContext, test patterns, MockContextOptions |
455
455
  | `api-workers` | `skills/api-workers/SKILL.md` | createWorkerHandler, CloudflareBindings, Worker runtime |
456
+ | `api-linter` | `skills/api-linter/SKILL.md` | Definition lint rules (`format-parity`, `schema-*`, `name-*`, `server-json-*`, …) — look here when devcheck reports a lint diagnostic |
456
457
  | `add-tool` | `skills/add-tool/SKILL.md` | Scaffold a new MCP tool definition |
457
458
  | `add-app-tool` | `skills/add-app-tool/SKILL.md` | Scaffold an MCP App tool + UI resource pair |
458
459
  | `add-resource` | `skills/add-resource/SKILL.md` | Scaffold a new MCP resource definition |
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  <div align="center">
7
7
 
8
- [![Version](https://img.shields.io/badge/Version-0.5.2-blue.svg?style=flat-square)](./CHANGELOG.md) [![MCP Spec](https://img.shields.io/badge/MCP%20Spec-2025--11--25-8A2BE2.svg?style=flat-square)](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-11-25/changelog.mdx) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^1.29.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE)
8
+ [![Version](https://img.shields.io/badge/Version-0.5.4-blue.svg?style=flat-square)](./CHANGELOG.md) [![MCP Spec](https://img.shields.io/badge/MCP%20Spec-2025--11--25-8A2BE2.svg?style=flat-square)](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-11-25/changelog.mdx) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^1.29.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE)
9
9
 
10
10
  [![TypeScript](https://img.shields.io/badge/TypeScript-^6.0.3-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.3.2-blueviolet.svg?style=flat-square)](https://bun.sh/)
11
11
 
@@ -1 +1 @@
1
- {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/linter/validate.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,OAAO,KAAK,EAAkB,SAAS,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAExE;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,SAAS,GAAG,UAAU,CA6DhE"}
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/linter/validate.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,OAAO,KAAK,EAAkB,SAAS,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAuBxE;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,SAAS,GAAG,UAAU,CA8DhE"}
@@ -9,6 +9,24 @@ import { lintPromptDefinition } from './rules/prompt-rules.js';
9
9
  import { lintResourceDefinition } from './rules/resource-rules.js';
10
10
  import { lintServerJson } from './rules/server-json-rules.js';
11
11
  import { lintAppToolResourcePairing, lintToolDefinition } from './rules/tool-rules.js';
12
+ /** Where the rule reference lives. Appended to every diagnostic message. */
13
+ const SKILL_REFERENCE_PATH = 'skills/api-linter/SKILL.md';
14
+ /**
15
+ * Maps a rule ID to its anchor in the api-linter skill doc. Most rules have
16
+ * a per-rule sub-header whose auto-generated anchor matches the rule ID. The
17
+ * server.json family (~40 rules) is documented in a single tabular section,
18
+ * so every `server-json-*` rule points to that section.
19
+ */
20
+ function ruleAnchor(rule) {
21
+ return rule.startsWith('server-json-') ? 'server-json-rules' : rule;
22
+ }
23
+ /** Appends a "See: skills/api-linter/SKILL.md#<rule>" breadcrumb to the message. */
24
+ function withBreadcrumb(diagnostic) {
25
+ return {
26
+ ...diagnostic,
27
+ message: `${diagnostic.message}\nSee: ${SKILL_REFERENCE_PATH}#${ruleAnchor(diagnostic.rule)}`,
28
+ };
29
+ }
12
30
  /**
13
31
  * Validates MCP tool, resource, and prompt definitions against the MCP spec
14
32
  * and framework conventions. Returns a structured report with errors and warnings.
@@ -71,8 +89,9 @@ export function validateDefinitions(input) {
71
89
  diagnostics.push(...checkDuplicateNames(extractNames(prompts), 'prompt'));
72
90
  // Cross-definition: app tool ↔ app resource pairing
73
91
  diagnostics.push(...lintAppToolResourcePairing(tools, resources));
74
- const errors = diagnostics.filter((d) => d.severity === 'error');
75
- const warnings = diagnostics.filter((d) => d.severity === 'warning');
92
+ const annotated = diagnostics.map(withBreadcrumb);
93
+ const errors = annotated.filter((d) => d.severity === 'error');
94
+ const warnings = annotated.filter((d) => d.severity === 'warning');
76
95
  return {
77
96
  errors,
78
97
  warnings,
@@ -1 +1 @@
1
- {"version":3,"file":"validate.js","sourceRoot":"","sources":["../../src/linter/validate.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"validate.js","sourceRoot":"","sources":["../../src/linter/validate.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAC5D,OAAO,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAC;AAC/D,OAAO,EAAE,sBAAsB,EAAE,MAAM,2BAA2B,CAAC;AACnE,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,OAAO,EAAE,0BAA0B,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAGvF,4EAA4E;AAC5E,MAAM,oBAAoB,GAAG,4BAA4B,CAAC;AAE1D;;;;;GAKG;AACH,SAAS,UAAU,CAAC,IAAY;IAC9B,OAAO,IAAI,CAAC,UAAU,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,CAAC,IAAI,CAAC;AACtE,CAAC;AAED,oFAAoF;AACpF,SAAS,cAAc,CAAC,UAA0B;IAChD,OAAO;QACL,GAAG,UAAU;QACb,OAAO,EAAE,GAAG,UAAU,CAAC,OAAO,UAAU,oBAAoB,IAAI,UAAU,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE;KAC9F,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,UAAU,mBAAmB,CAAC,KAAgB;IAClD,MAAM,WAAW,GAAqB,EAAE,CAAC;IACzC,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,IAAI,EAAE,CAAC;IAChC,MAAM,SAAS,GAAG,KAAK,CAAC,SAAS,IAAI,EAAE,CAAC;IACxC,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,IAAI,EAAE,CAAC;IAEpC,4BAA4B;IAC5B,KAAK,MAAM,GAAG,IAAI,KAAK,EAAE,CAAC;QACxB,WAAW,CAAC,IAAI,CAAC,GAAG,kBAAkB,CAAC,GAAG,CAAC,CAAC,CAAC;IAC/C,CAAC;IACD,KAAK,MAAM,GAAG,IAAI,SAAS,EAAE,CAAC;QAC5B,WAAW,CAAC,IAAI,CAAC,GAAG,sBAAsB,CAAC,GAAG,CAAC,CAAC,CAAC;IACnD,CAAC;IACD,KAAK,MAAM,GAAG,IAAI,OAAO,EAAE,CAAC;QAC1B,WAAW,CAAC,IAAI,CAAC,GAAG,oBAAoB,CAAC,GAAG,CAAC,CAAC,CAAC;IACjD,CAAC;IAED,kCAAkC;IAClC,IAAI,KAAK,CAAC,UAAU,IAAI,IAAI,EAAE,CAAC;QAC7B,MAAM,UAAU,GAAG,KAAK,CAAC,WAAW,EAAE,OAAO,CAAC;QAC9C,WAAW,CAAC,IAAI,CACd,GAAG,cAAc,CACf,KAAK,CAAC,UAAU,EAChB,UAAU,CAAC,CAAC,CAAC,EAAE,kBAAkB,EAAE,UAAU,EAAE,CAAC,CAAC,CAAC,SAAS,CAC5D,CACF,CAAC;IACJ,CAAC;IAED,oCAAoC;IACpC,MAAM,YAAY,GAAG,CAAC,IAAe,EAAE,EAAE,CACvC,IAAI;SACD,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAE,CAA6B,EAAE,IAAI,CAAC;SAChD,MAAM,CAAC,CAAC,CAAC,EAAe,EAAE,CAAC,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAEvE,WAAW,CAAC,IAAI,CAAC,GAAG,mBAAmB,CAAC,YAAY,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC;IAEtE,MAAM,aAAa,GAAG,SAAS;SAC5B,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;QACT,MAAM,CAAC,GAAG,CAA4B,CAAC;QACvC,OAAO,OAAO,CAAC,EAAE,IAAI,KAAK,QAAQ;YAChC,CAAC,CAAC,CAAC,CAAC,IAAI;YACR,CAAC,CAAC,OAAO,CAAC,EAAE,WAAW,KAAK,QAAQ;gBAClC,CAAC,CAAC,CAAC,CAAC,WAAW;gBACf,CAAC,CAAC,EAAE,CAAC;IACX,CAAC,CAAC;SACD,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAC/B,WAAW,CAAC,IAAI,CAAC,GAAG,mBAAmB,CAAC,aAAa,EAAE,UAAU,CAAC,CAAC,CAAC;IAEpE,WAAW,CAAC,IAAI,CAAC,GAAG,mBAAmB,CAAC,YAAY,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC;IAE1E,oDAAoD;IACpD,WAAW,CAAC,IAAI,CAAC,GAAG,0BAA0B,CAAC,KAAK,EAAE,SAAS,CAAC,CAAC,CAAC;IAElE,MAAM,SAAS,GAAG,WAAW,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAClD,MAAM,MAAM,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAC;IAC/D,MAAM,QAAQ,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC;IAEnE,OAAO;QACL,MAAM;QACN,QAAQ;QACR,MAAM,EAAE,MAAM,CAAC,MAAM,KAAK,CAAC;KAC5B,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyanheads/mcp-ts-core",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "mcpName": "io.github.cyanheads/mcp-ts-core",
5
5
  "description": "Agent-native TypeScript framework for building MCP servers. Build tools, not infrastructure. Declarative definitions with auth, multi-backend storage, OpenTelemetry, and first-class support for Node.js and Cloudflare Workers.",
6
6
  "main": "dist/core/index.js",
@@ -67,7 +67,7 @@ const createColor = (open: string, close: string, closeRe: RegExp) => (str: stri
67
67
  return open + `${str}`.replace(closeRe, close + open) + close;
68
68
  };
69
69
 
70
- const esc = (code: string) => new RegExp(code.replace('[', '\\['), 'g');
70
+ const esc = (code: string) => new RegExp(code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
71
71
  const c = {
72
72
  bold: createColor('\x1b[1m', '\x1b[22m', esc('\x1b[22m')),
73
73
  dim: createColor('\x1b[2m', '\x1b[22m', esc('\x1b[22m')),
@@ -411,7 +411,7 @@ const ALL_CHECKS: Check[] = [
411
411
  canFix: false,
412
412
  getCommand: () => ['bun', 'run', 'scripts/lint-mcp.ts'],
413
413
  tip: (c) =>
414
- `Fix definition errors reported above. See ${c.bold('validateDefinitions()')} docs for rule details.`,
414
+ `Fix definition errors above each diagnostic links to its rule in ${c.bold('skills/api-linter/SKILL.md')}.`,
415
415
  },
416
416
  {
417
417
  name: 'Docs Sync',
@@ -562,7 +562,7 @@ const ALL_CHECKS: Check[] = [
562
562
  return unexpected.length === 0;
563
563
  },
564
564
  tip: (c) =>
565
- `Run ${c.bold(`${PM_CMD} update`)} to upgrade dependencies. Configure allowlist in ${c.bold('devcheck.config.json')}.`,
565
+ `Run ${c.bold(`${PM_CMD} update`)} to upgrade; the ${c.bold('maintenance')} skill then investigates changelogs and adopts upstream changes. Configure allowlist in ${c.bold('devcheck.config.json')}.`,
566
566
  },
567
567
  ];
568
568
 
@@ -0,0 +1,391 @@
1
+ ---
2
+ name: api-linter
3
+ description: >
4
+ MCP definition linter rules reference. Use when `bun run lint:mcp`, `bun run devcheck`, or `createApp()` startup 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.
5
+ metadata:
6
+ author: cyanheads
7
+ version: "1.0"
8
+ audience: external
9
+ type: reference
10
+ ---
11
+
12
+ ## Overview
13
+
14
+ The linter validates tool, resource, and prompt definitions against the MCP spec and framework conventions. It runs in three places:
15
+
16
+ | Entry point | When | On failure |
17
+ |:------------|:-----|:-----------|
18
+ | `createApp()` / `createWorkerHandler()` | Every startup | Throws `ConfigurationError`; process exits with a formatted banner. Warnings are logged and startup continues. |
19
+ | `bun run lint:mcp` | Manual or CI | Prints errors + warnings, exits non-zero on errors. |
20
+ | `bun run devcheck` | Pre-commit workflow | Wraps `lint:mcp` alongside typecheck, format, `bun audit`, `bun outdated`. |
21
+
22
+ All three 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.
23
+
24
+ **Severity:**
25
+ - **error** — MUST-level spec violation; blocks startup.
26
+ - **warning** — SHOULD-level or quality issue; logged but startup continues.
27
+
28
+ **Imports (if you need to run the linter programmatically):**
29
+
30
+ ```ts
31
+ import { validateDefinitions } from '@cyanheads/mcp-ts-core/linter';
32
+ import type { LintReport, LintDiagnostic } from '@cyanheads/mcp-ts-core/linter';
33
+
34
+ const report = validateDefinitions({ tools, resources, prompts, serverJson, packageJson });
35
+ if (!report.passed) process.exit(1);
36
+ ```
37
+
38
+ ---
39
+
40
+ ## Rule index
41
+
42
+ Grouped by family. Jump to any rule ID via its anchor.
43
+
44
+ | Family | Rules | Section |
45
+ |:-------|:------|:--------|
46
+ | Format parity | `format-parity`, `format-parity-threw`, `format-parity-walk-failed` | [Format parity](#format-parity) |
47
+ | Schema | `schema-is-object`, `describe-on-fields`, `schema-serializable` | [Schema rules](#schema-rules) |
48
+ | Names | `name-required`, `name-format`, `name-unique` | [Name rules](#name-rules) |
49
+ | Tools | `description-required`, `handler-required`, `auth-type`, `auth-scope-format`, `annotation-type`, `annotation-coherence`, `meta-ui-type`, `meta-ui-resource-uri-required`, `meta-ui-resource-uri-scheme`, `app-tool-resource-pairing` | [Tool rules](#tool-rules) |
50
+ | Resources | `uri-template-required`, `uri-template-valid`, `resource-name-not-uri`, `template-params-align` | [Resource rules](#resource-rules) |
51
+ | Prompts | `generate-required` | [Prompt rules](#prompt-rules) |
52
+ | server.json | ~40 rules prefixed `server-json-*` | [server.json rules](#server-json-rules) |
53
+
54
+ ---
55
+
56
+ ## Format parity
57
+
58
+ 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.
59
+
60
+ ### format-parity
61
+
62
+ **Severity:** error
63
+
64
+ 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.
65
+
66
+ **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:
67
+
68
+ ```ts
69
+ output: z.discriminatedUnion('mode', [
70
+ z.object({ mode: z.literal('list'), items: z.array(ItemSchema) }),
71
+ z.object({ mode: z.literal('detail'), item: ItemSchema, history: z.array(HistoryEntry) }),
72
+ ]),
73
+
74
+ format: (result) => {
75
+ if (result.mode === 'list') return renderList(result.items);
76
+ return renderDetail(result.item, result.history);
77
+ }
78
+ ```
79
+
80
+ **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:
81
+
82
+ ```ts
83
+ output: z.object({}).passthrough()
84
+ ```
85
+
86
+ `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.
87
+
88
+ **Anti-pattern:** summary-only `format()` like `return [{ type: 'text', text: \`Found ${n} items\` }]`. The sentinel walk will flag every field in the items array. Don't "fix" this by removing fields from `output` — that makes `structuredContent` clients blind too.
89
+
90
+ ### format-parity-threw
91
+
92
+ **Severity:** warning
93
+
94
+ Fires when `format()` throws while being called with a synthetic sample. The linter cannot verify parity because your formatter crashed before producing output.
95
+
96
+ **Fix:** `format()` must be **total** — render any valid value of the output schema without throwing. Common causes:
97
+
98
+ - Assuming an optional array is always present (`result.items.map(...)` when `items` could be `undefined`)
99
+ - Dereferencing a discriminated-union branch without checking the discriminator
100
+ - Calling `toFixed()` or `toISOString()` on a value that could legitimately be any number/string
101
+
102
+ 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.
103
+
104
+ ### format-parity-walk-failed
105
+
106
+ **Severity:** warning
107
+
108
+ 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.
109
+
110
+ **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.
111
+
112
+ ---
113
+
114
+ ## Schema rules
115
+
116
+ ### schema-is-object
117
+
118
+ **Severity:** error
119
+
120
+ 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.
121
+
122
+ **Fix:** wrap whatever you had in a single-key object:
123
+
124
+ ```ts
125
+ // Wrong
126
+ input: z.array(z.string())
127
+ // Right
128
+ input: z.object({ items: z.array(z.string()).describe('List of items') })
129
+ ```
130
+
131
+ ### describe-on-fields
132
+
133
+ **Severity:** warning
134
+
135
+ 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.
136
+
137
+ **Fix:** add `.describe('...')` to every leaf. The linter reports which path is missing a description (e.g., `input.filters.status`), so it's a mechanical fix.
138
+
139
+ ### schema-serializable
140
+
141
+ **Severity:** error
142
+
143
+ 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.
144
+
145
+ **Disallowed:** `z.custom()`, `z.date()`, `z.transform()`, `z.bigint()`, `z.symbol()`, `z.void()`, `z.map()`, `z.set()`, `z.function()`, `z.nan()`.
146
+
147
+ **Fix:** use structural equivalents. Most common swap:
148
+
149
+ ```ts
150
+ // Wrong
151
+ z.date()
152
+ // Right
153
+ z.string().describe('ISO 8601 timestamp, e.g., 2026-04-20T12:00:00Z')
154
+ ```
155
+
156
+ Parse the string to a `Date` inside the handler if you need one.
157
+
158
+ ---
159
+
160
+ ## Name rules
161
+
162
+ ### name-required
163
+
164
+ **Severity:** error
165
+
166
+ 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`).
167
+
168
+ ### name-format
169
+
170
+ **Severity:** error
171
+
172
+ Names must match `^[a-zA-Z0-9._-]+$` (alphanumerics, dots, hyphens, underscores). Tools conventionally use `snake_case`, resources and prompts use `kebab-case` or `snake_case`.
173
+
174
+ **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.
175
+
176
+ ### name-unique
177
+
178
+ **Severity:** error
179
+
180
+ Tool names, resource names, and prompt names must each be unique within their type. Duplicates would cause the client to see only one.
181
+
182
+ **Fix:** rename one, or consolidate into a single definition if they're actually the same tool.
183
+
184
+ ---
185
+
186
+ ## Tool rules
187
+
188
+ ### description-required
189
+
190
+ **Severity:** warning
191
+
192
+ 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.
193
+
194
+ Also applies to resources and prompts (same rule ID, different `definitionType`).
195
+
196
+ **Fix:** write a single cohesive paragraph. Prose, not bullet lists. Descriptions render inline in most clients.
197
+
198
+ ### handler-required
199
+
200
+ **Severity:** error
201
+
202
+ 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.
203
+
204
+ Also applies to resources (same rule ID, different `definitionType`).
205
+
206
+ ### auth-type
207
+
208
+ **Severity:** error
209
+
210
+ `auth` must be an array of strings. A single string or other shape is rejected.
211
+
212
+ ```ts
213
+ // Wrong
214
+ auth: 'tool:my_tool:read'
215
+ // Right
216
+ auth: ['tool:my_tool:read']
217
+ ```
218
+
219
+ ### auth-scope-format
220
+
221
+ **Severity:** error
222
+
223
+ Every element in `auth` must be a non-empty string. Empty strings in the array are rejected — they'd match anything.
224
+
225
+ ### annotation-type
226
+
227
+ **Severity:** warning
228
+
229
+ `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.
230
+
231
+ ### annotation-coherence
232
+
233
+ **Severity:** warning
234
+
235
+ Contradictory annotation combinations. The canonical case: `readOnlyHint: true` with `destructiveHint: true` — a read-only tool cannot be destructive. `idempotentHint: true` alongside `readOnlyHint: true` is fine (explicit redundancy is allowed).
236
+
237
+ ### meta-ui-type
238
+
239
+ **Severity:** error (MCP Apps tools only)
240
+
241
+ When a tool declares `_meta.ui`, that field must be an object. `null`, arrays, or primitives are rejected.
242
+
243
+ ### meta-ui-resource-uri-required
244
+
245
+ **Severity:** error (MCP Apps tools only)
246
+
247
+ `_meta.ui.resourceUri` must be a non-empty string. This is the URI the client resolves to load the app UI.
248
+
249
+ ### meta-ui-resource-uri-scheme
250
+
251
+ **Severity:** warning (MCP Apps tools only)
252
+
253
+ `_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.
254
+
255
+ ### app-tool-resource-pairing
256
+
257
+ **Severity:** warning (MCP Apps tools only)
258
+
259
+ 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.
260
+
261
+ **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.
262
+
263
+ ---
264
+
265
+ ## Resource rules
266
+
267
+ ### uri-template-required
268
+
269
+ **Severity:** error
270
+
271
+ Every resource needs a non-empty `uriTemplate` string. The URI template is the resource's primary identifier.
272
+
273
+ ### uri-template-valid
274
+
275
+ **Severity:** error
276
+
277
+ `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.
278
+
279
+ ### resource-name-not-uri
280
+
281
+ **Severity:** warning
282
+
283
+ 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.
284
+
285
+ **Fix:** add a short `name` field:
286
+
287
+ ```ts
288
+ resource('myscheme://{id}/data', {
289
+ name: 'Item data', // <-- add this
290
+ // ...
291
+ })
292
+ ```
293
+
294
+ ### template-params-align
295
+
296
+ **Severity:** error
297
+
298
+ Every variable in the URI template must appear as a key in the `params` schema, and vice versa. `test://{itemId}/data` with `params: z.object({ item_id: ... })` is rejected — casing mismatches count.
299
+
300
+ **Fix:** rename one side so they match exactly. The error message names which variables are on which side.
301
+
302
+ ---
303
+
304
+ ## Prompt rules
305
+
306
+ ### generate-required
307
+
308
+ **Severity:** error
309
+
310
+ Every prompt needs a `generate` function that returns the message array. Prompts without `generate` have nothing to produce.
311
+
312
+ (Prompts also share `name-*` and `description-required` rules from their respective families.)
313
+
314
+ ---
315
+
316
+ ## server.json rules
317
+
318
+ Validates the `server.json` manifest at project root against the [MCP server manifest spec](https://modelcontextprotocol.io/specification). Every rule below fires only when a `server.json` is present.
319
+
320
+ | Rule ID | Severity | What it checks |
321
+ |:--------|:---------|:---------------|
322
+ | `server-json-type` | error | `server.json` must be a JSON object, not an array or primitive |
323
+ | `server-json-name-required` | error | `name` must be present and non-empty |
324
+ | `server-json-name-length` | error | `name` length 3–200 characters |
325
+ | `server-json-name-format` | error | `name` must match reverse-DNS pattern `owner/project` |
326
+ | `server-json-description-required` | error | `description` must be present and non-empty |
327
+ | `server-json-description-length` | warning | `description` > 100 chars — some registries truncate |
328
+ | `server-json-version-required` | error | `version` must be present |
329
+ | `server-json-version-length` | error | `version` length ≤ 255 |
330
+ | `server-json-version-no-range` | error | `version` must be a specific version, not a range (`^`, `~`, `>=`, etc.) |
331
+ | `server-json-version-semver` | warning | `version` should be valid semver (`major.minor.patch`) |
332
+ | `server-json-version-sync` | warning | `server.json` `version` should match `package.json` `version` |
333
+ | `server-json-repository-type` | error | `repository` must be an object |
334
+ | `server-json-repository-url` | error | `repository.url` is required when `repository` is present |
335
+ | `server-json-repository-source` | error | `repository.source` is required when `repository` is present |
336
+ | `server-json-packages-type` | error | `packages` must be an array |
337
+ | `server-json-package-type` | error | Each `packages[i]` must be an object |
338
+ | `server-json-package-registry` | error | `packages[i].registryType` is required |
339
+ | `server-json-package-identifier` | error | `packages[i].identifier` is required |
340
+ | `server-json-package-transport` | error | `packages[i].transport` is required |
341
+ | `server-json-package-no-latest` | error | `packages[i].version` must not be `"latest"` — pin a specific version |
342
+ | `server-json-package-version-sync` | warning | `packages[i].version` should match root `version` |
343
+ | `server-json-package-args-type` | error | `packages[i].packageArguments` must be an array |
344
+ | `server-json-runtime-args-type` | error | `packages[i].runtimeArguments` must be an array |
345
+ | `server-json-env-vars-type` | error | `packages[i].environmentVariables` must be an array |
346
+ | `server-json-remotes-type` | error | `remotes` must be an array |
347
+ | `server-json-remote-type` | error | Each `remotes[i]` must be an object |
348
+ | `server-json-remote-transport-type` | error | `remotes[i].type` is required |
349
+ | `server-json-remote-no-stdio` | error | `remotes[i].type` must be `streamable-http` or `sse` — `stdio` is not valid for remotes |
350
+ | `server-json-transport-type` | error | `transport` must be an object |
351
+ | `server-json-transport-type-value` | error | `transport.type` must be one of `stdio`, `streamable-http`, `sse` |
352
+ | `server-json-transport-url-required` | error | `transport.url` required for `streamable-http` and `sse` |
353
+ | `server-json-transport-url-format` | warning | `transport.url` should be `http://` or `https://` |
354
+ | `server-json-argument-type` | error | Each argument must be an object |
355
+ | `server-json-argument-type-value` | error | `argument.type` must be `positional` or `named` |
356
+ | `server-json-argument-name` | error | Named arguments require `name` |
357
+ | `server-json-argument-value` | error | Positional arguments require `value` or `valueHint` |
358
+ | `server-json-input-format` | warning | `format` should be `string`, `number`, `boolean`, or `filepath` |
359
+ | `server-json-env-var-type` | error | Each environment variable must be an object |
360
+ | `server-json-env-var-name` | error | Environment variable `name` is required |
361
+ | `server-json-env-var-description` | warning | Environment variables should have a `description` |
362
+
363
+ 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.
364
+
365
+ ---
366
+
367
+ ## Escape hatches
368
+
369
+ ### Dynamic upstream data
370
+
371
+ 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.
372
+
373
+ ### Temporarily suppress a warning
374
+
375
+ 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.
376
+
377
+ ### Escape isn't "make it pass"
378
+
379
+ 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).
380
+
381
+ ---
382
+
383
+ ## Adding a new rule
384
+
385
+ If you're extending `@cyanheads/mcp-ts-core` with a new lint rule:
386
+
387
+ 1. Add the rule to `src/linter/rules/<family>-rules.ts`. Return `LintDiagnostic` objects with a stable `rule` ID.
388
+ 2. Wire it into `validateDefinitions()` in `src/linter/validate.ts` if it's a new family.
389
+ 3. Add tests in `tests/unit/linter/`.
390
+ 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.
391
+ 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.