@cyanheads/mcp-ts-core 0.13.8 → 0.13.9

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.
Files changed (102) hide show
  1. package/AGENTS.md +17 -13
  2. package/CLAUDE.md +17 -13
  3. package/README.md +2 -2
  4. package/changelog/0.13.x/0.13.9.md +113 -0
  5. package/dist/config/index.d.ts.map +1 -1
  6. package/dist/config/index.js +20 -9
  7. package/dist/config/index.js.map +1 -1
  8. package/dist/core/app.d.ts +6 -3
  9. package/dist/core/app.d.ts.map +1 -1
  10. package/dist/core/app.js +6 -4
  11. package/dist/core/app.js.map +1 -1
  12. package/dist/core/context.d.ts +25 -1
  13. package/dist/core/context.d.ts.map +1 -1
  14. package/dist/core/context.js.map +1 -1
  15. package/dist/core/serverManifest.d.ts +6 -0
  16. package/dist/core/serverManifest.d.ts.map +1 -1
  17. package/dist/core/serverManifest.js +6 -0
  18. package/dist/core/serverManifest.js.map +1 -1
  19. package/dist/linter/rules/tool-rules.d.ts +2 -1
  20. package/dist/linter/rules/tool-rules.d.ts.map +1 -1
  21. package/dist/linter/rules/tool-rules.js +36 -1
  22. package/dist/linter/rules/tool-rules.js.map +1 -1
  23. package/dist/mcp-server/inputRequired.d.ts +14 -5
  24. package/dist/mcp-server/inputRequired.d.ts.map +1 -1
  25. package/dist/mcp-server/inputRequired.js +15 -8
  26. package/dist/mcp-server/inputRequired.js.map +1 -1
  27. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts +163 -40
  28. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts.map +1 -1
  29. package/dist/mcp-server/tools/utils/inputPrevalidation.js +330 -114
  30. package/dist/mcp-server/tools/utils/inputPrevalidation.js.map +1 -1
  31. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +23 -10
  32. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  33. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +296 -80
  34. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  35. package/dist/mcp-server/transports/http/httpTransport.d.ts.map +1 -1
  36. package/dist/mcp-server/transports/http/httpTransport.js +65 -9
  37. package/dist/mcp-server/transports/http/httpTransport.js.map +1 -1
  38. package/dist/mcp-server/transports/stdio/stdioTransport.d.ts +9 -5
  39. package/dist/mcp-server/transports/stdio/stdioTransport.d.ts.map +1 -1
  40. package/dist/mcp-server/transports/stdio/stdioTransport.js +9 -5
  41. package/dist/mcp-server/transports/stdio/stdioTransport.js.map +1 -1
  42. package/dist/services/canvas/core/CanvasRegistry.d.ts +6 -2
  43. package/dist/services/canvas/core/CanvasRegistry.d.ts.map +1 -1
  44. package/dist/services/canvas/core/CanvasRegistry.js +7 -3
  45. package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
  46. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts +82 -18
  47. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts.map +1 -1
  48. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js +620 -328
  49. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js.map +1 -1
  50. package/dist/services/canvas/providers/duckdb/exportWriter.d.ts +11 -7
  51. package/dist/services/canvas/providers/duckdb/exportWriter.d.ts.map +1 -1
  52. package/dist/services/canvas/providers/duckdb/exportWriter.js +19 -16
  53. package/dist/services/canvas/providers/duckdb/exportWriter.js.map +1 -1
  54. package/dist/services/mirror/core/defineMirror.d.ts +1 -0
  55. package/dist/services/mirror/core/defineMirror.d.ts.map +1 -1
  56. package/dist/services/mirror/core/defineMirror.js +1 -0
  57. package/dist/services/mirror/core/defineMirror.js.map +1 -1
  58. package/dist/utils/index.d.ts +1 -1
  59. package/dist/utils/index.d.ts.map +1 -1
  60. package/dist/utils/index.js.map +1 -1
  61. package/dist/utils/network/pacer.d.ts +38 -5
  62. package/dist/utils/network/pacer.d.ts.map +1 -1
  63. package/dist/utils/network/pacer.js +87 -25
  64. package/dist/utils/network/pacer.js.map +1 -1
  65. package/dist/utils/telemetry/attributes.d.ts +5 -1
  66. package/dist/utils/telemetry/attributes.d.ts.map +1 -1
  67. package/dist/utils/telemetry/attributes.js +5 -1
  68. package/dist/utils/telemetry/attributes.js.map +1 -1
  69. package/framework-skills/add-app-tool/SKILL.md +3 -3
  70. package/framework-skills/add-export/SKILL.md +5 -16
  71. package/framework-skills/add-prompt/SKILL.md +7 -3
  72. package/framework-skills/add-resource/SKILL.md +7 -5
  73. package/framework-skills/add-tool/SKILL.md +12 -10
  74. package/framework-skills/api-auth/SKILL.md +2 -2
  75. package/framework-skills/api-canvas/SKILL.md +17 -8
  76. package/framework-skills/api-config/SKILL.md +4 -4
  77. package/framework-skills/api-context/SKILL.md +14 -3
  78. package/framework-skills/api-errors/SKILL.md +8 -7
  79. package/framework-skills/api-linter/SKILL.md +26 -7
  80. package/framework-skills/api-mirror/SKILL.md +2 -1
  81. package/framework-skills/api-telemetry/SKILL.md +4 -4
  82. package/framework-skills/api-utils/SKILL.md +2 -2
  83. package/framework-skills/design-mcp-server/SKILL.md +2 -2
  84. package/framework-skills/field-test/SKILL.md +4 -4
  85. package/framework-skills/git-wrapup/SKILL.md +8 -6
  86. package/framework-skills/orchestrations/SKILL.md +7 -6
  87. package/framework-skills/orchestrations/workflows/field-test-fix.md +9 -19
  88. package/framework-skills/orchestrations/workflows/fix-wrapup-release.md +7 -7
  89. package/framework-skills/orchestrations/workflows/greenfield-build.md +8 -5
  90. package/framework-skills/orchestrations/workflows/maintenance-release.md +8 -8
  91. package/framework-skills/polish-docs-meta/SKILL.md +4 -4
  92. package/framework-skills/release-and-publish/SKILL.md +6 -4
  93. package/framework-skills/release-pr-review/SKILL.md +37 -23
  94. package/framework-skills/report-issue-framework/SKILL.md +7 -5
  95. package/framework-skills/report-issue-local/SKILL.md +8 -6
  96. package/framework-skills/security-pass/SKILL.md +8 -8
  97. package/package.json +3 -3
  98. package/scripts/devcheck.ts +7 -6
  99. package/scripts/lint-mcp.ts +87 -27
  100. package/scripts/lint-packaging.ts +61 -0
  101. package/scripts/release-github.ts +117 -5
  102. package/templates/_.mcpbignore +2 -0
@@ -4,7 +4,7 @@ description: >
4
4
  File a bug or feature request against this MCP server's own repo. Use for server-specific issues — tool logic, service integrations, config problems, or domain bugs that aren't caused by the framework.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.11"
7
+ version: "1.12"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -38,7 +38,8 @@ gh repo view --json nameWithOwner -q '.nameWithOwner'
38
38
  gh issue list --search "your error message or keyword" --state all
39
39
 
40
40
  # Assess a close match before commenting — is it already linked to a fix or referenced elsewhere?
41
- gh issue view <number> --comments
41
+ gh issue view <number> # body
42
+ gh issue view <number> --comments # thread only — without a TTY it prints no body
42
43
  gh api 'repos/{owner}/{repo}/issues/<number>/timeline' --paginate \
43
44
  --jq '.[] | select(.event=="cross-referenced") | .source.issue | "\(.repository.full_name)#\(.number) — \(.title)"'
44
45
  ```
@@ -87,11 +88,11 @@ gh issue create \
87
88
  --body "$(cat <<'ISSUE'
88
89
  ### Server version
89
90
 
90
- 0.1.0
91
+ <package.json version>
91
92
 
92
93
  ### mcp-ts-core version
93
94
 
94
- 0.1.29
95
+ <installed version from node_modules/@cyanheads/mcp-ts-core/package.json — not the ^ range>
95
96
 
96
97
  ### Runtime
97
98
 
@@ -99,7 +100,7 @@ Bun
99
100
 
100
101
  ### Runtime version
101
102
 
102
- Bun 1.3.x
103
+ <bun --version>
103
104
 
104
105
  ### Transport
105
106
 
@@ -258,7 +259,8 @@ When genuinely ambiguous, file against this server's repo and note that it might
258
259
  ## Following Up
259
260
 
260
261
  ```bash
261
- # View issue details (with comment thread)
262
+ # View the issue body, then its comment thread (--comments without a TTY prints no body)
263
+ gh issue view <number>
262
264
  gh issue view <number> --comments
263
265
 
264
266
  # Add context
@@ -4,7 +4,7 @@ description: >
4
4
  Review an MCP server for common security gaps: LLM-facing surfaces as injection vector (tools, resources, prompts, descriptions), scope blast radius, destructive ops without consent, upstream auth shape, input sinks (URL / path / roots / shell / schema strictness / ReDoS), tenant isolation, leakage through errors and telemetry, unbounded resources, and HTTP-mode deployment surface. Use before a release, after a batch of handler changes, or when the user asks for a security review, audit, or hardening pass. Produces grouped findings and a numbered options list.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.10"
7
+ version: "1.11"
8
8
  audience: external
9
9
  type: audit
10
10
  ---
@@ -116,7 +116,7 @@ grep -rn "auth: \[" src/mcp-server/tools/definitions/
116
116
 
117
117
  ```bash
118
118
  grep -rn "destructiveHint" src/mcp-server/tools/definitions/
119
- grep -rn "ctx.requestInput\|ctx.inputs" src/mcp-server/tools/definitions/
119
+ grep -rnE "ctx\.requestInput|ctx\.inputs" src/mcp-server/tools/definitions/
120
120
  ```
121
121
 
122
122
  **Check:**
@@ -127,7 +127,7 @@ grep -rn "ctx.requestInput\|ctx.inputs" src/mcp-server/tools/definitions/
127
127
  - Consent is scoped to the specific target (e.g., record ID rendered in the message), not a generic "proceed?"
128
128
  - Any `requestState` carried across rounds is integrity-protected if it influences authorization, resource access, or which target gets mutated. It round-trips through the client and comes back attacker-controlled; the SDK does not sign or verify it.
129
129
  - **A consent gate's state is server-issued and single-use.** A client can send `inputResponses` plus a `requestState` of its own on the very FIRST call — honored on 2025-era connections, even from a client that declared no `elicitation` — so a handler that only *compares* client-carried state against a fresh resolution deletes on a forged "accepted" answer without ever prompting. A signed state closes forgery but not replay within its TTL. Keep the confirmed target (plus a content hash, so a same-path swap is caught) in a server-side record keyed by a random id, send only the id, redeem it before anything else in the handler, and refuse an unknown, used, or expired id.
130
- - **The weak point is answerability, not availability.** `ctx.requestInput` is present on every transport and both protocol eras — the 2025-era shim issues the real `elicitation/create` round trip, the 2026-07-28 client fulfils the embedded request directly. A client that never retries simply leaves the destructive step un-run, which fails safe. Keep `destructiveHint: true` so client-side approval flows still surface the risk, and do not accept "proceed anyway when the round is unavailable" as a fallback — there is no such state to detect.
130
+ - **The weak point is answerability, not availability.** `ctx.requestInput` is present on every transport and both protocol eras — the 2025-era shim issues the real `elicitation/create` round trip, the 2026-07-28 client fulfils the embedded request directly. A client that never retries simply leaves the destructive step un-run, which fails safe. Keep `destructiveHint: true` so client-side approval flows still surface the risk, and do not accept "proceed anyway when the round is unavailable" as a fallback. On a 2025-era connection whose client lacks the capability, `ctx.requestInput` throws `client_capability_missing` inside the handler; catching that to run the side effect is exactly this bypass — let it propagate.
131
131
 
132
132
  **Smell:** `destructiveHint: true` file with no `ctx.requestInput` in it. Or `ctx.inputs.accepted('confirm')` with no schema argument — the content could be anything. Or a handler that re-issues the same request after a `decline`.
133
133
 
@@ -157,22 +157,22 @@ LLM-supplied inputs feel internal but aren't. Classic sinks apply, amplified. Sa
157
157
  grep -rn "z.string().url()" src/
158
158
 
159
159
  # Path sinks — traversal
160
- grep -rn "readFile\|writeFile\|readdirSync\|createReadStream\|statSync" src/
160
+ grep -rnE "readFile|writeFile|readdirSync|createReadStream|statSync" src/
161
161
 
162
162
  # Shell sinks — command injection
163
163
  grep -rnE "\b(exec|spawn|execSync|spawnSync)\b" src/
164
164
 
165
165
  # Merges — prototype pollution
166
- grep -rn "Object.assign\b\|structuredClone" src/
166
+ grep -rnE "Object\.assign\b|structuredClone" src/
167
167
 
168
168
  # Lookups — prototype chain read through an object literal
169
169
  grep -rnE "\[[a-zA-Z_$][a-zA-Z0-9_$.]*\] *\?\? |\[[a-zA-Z_$][a-zA-Z0-9_$.]*\] *\|\| " src/
170
170
 
171
171
  # Roots — client-shared filesystem
172
- grep -rn "roots/list\|ctx.roots" src/
172
+ grep -rnE "roots/list|ctx\.roots" src/
173
173
 
174
174
  # Schema laxity — fields sneaking past validation
175
- grep -rn "\.passthrough()\|\.loose()\|looseObject(\|\.catchall(" src/mcp-server/
175
+ grep -rnE "\.passthrough\(\)|\.loose\(\)|looseObject\(|\.catchall\(" src/mcp-server/
176
176
  ```
177
177
 
178
178
  **Check:**
@@ -245,7 +245,7 @@ Unbounded = DoS of self, upstream, or the LLM's context window (billing-DoS is r
245
245
 
246
246
  ```bash
247
247
  grep -rnE "while\s*\(|for\s*\(.*of" src/mcp-server/tools/definitions/
248
- grep -rn "cursor\|nextPage\|paginate" src/
248
+ grep -rnE "cursor|nextPage|paginate" src/
249
249
  grep -rn "JSON.parse\b" src/
250
250
  ```
251
251
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyanheads/mcp-ts-core",
3
- "version": "0.13.8",
3
+ "version": "0.13.9",
4
4
  "mcpName": "io.github.cyanheads/mcp-ts-core",
5
5
  "description": "Agent-native TypeScript framework for MCP servers. Includes runtime infrastructure and agent skills for building, testing, and shipping servers.",
6
6
  "files": [
@@ -201,7 +201,7 @@
201
201
  "@cloudflare/workers-types": "5.20260922.1",
202
202
  "@duckdb/node-api": "^1.5.5-r.5",
203
203
  "@hono/otel": "^1.1.2",
204
- "@modelcontextprotocol/client": "^2.0.0",
204
+ "@modelcontextprotocol/client": "^2.1.0",
205
205
  "@opentelemetry/api-logs": "^0.222.0",
206
206
  "@opentelemetry/exporter-logs-otlp-http": "^0.222.0",
207
207
  "@opentelemetry/exporter-metrics-otlp-http": "^0.222.0",
@@ -290,7 +290,7 @@
290
290
  },
291
291
  "dependencies": {
292
292
  "@hono/node-server": "^2.1.1",
293
- "@modelcontextprotocol/server": "^2.0.0",
293
+ "@modelcontextprotocol/server": "^2.1.0",
294
294
  "@opentelemetry/api": "^1.9.1",
295
295
  "hono": "^4.13.8",
296
296
  "jose": "^6.2.12",
@@ -711,12 +711,12 @@ const ALL_CHECKS: Check[] = [
711
711
  canFix: false,
712
712
  // Validates env var alignment between manifest.json (MCPB bundle) and
713
713
  // server.json (MCP Registry), plus plugin marketplace manifests (#240), the
714
- // bundle-content guards on .mcpbignore (#343), and the README version badge
715
- // (#418). Runs when any of those inputs is present; skipped cleanly when
716
- // none exist — consumers on an HTTP-only deploy are unaffected. README.md is
717
- // a trigger in its own right: the badge check must gate a project that
718
- // carries no bundle or plugin metadata at all, which the other three inputs
719
- // only covered incidentally.
714
+ // bundle-content guards on .mcpbignore (#343), the README version badge
715
+ // (#418), and the Dockerfile build platform. Runs when any of those inputs
716
+ // is present; skipped cleanly when none exist — consumers on an HTTP-only
717
+ // deploy are unaffected. README.md and Dockerfile are triggers in their own
718
+ // right: each check must gate a project that carries no bundle or plugin
719
+ // metadata at all, which the other inputs only covered incidentally.
720
720
  getCommand: () => {
721
721
  const inputs = [
722
722
  'manifest.json',
@@ -725,6 +725,7 @@ const ALL_CHECKS: Check[] = [
725
725
  '.codex-plugin/mcp.json',
726
726
  '.mcpbignore',
727
727
  'README.md',
728
+ 'Dockerfile',
728
729
  ];
729
730
  if (!inputs.some((input) => existsSync(path.join(ROOT_DIR, input)))) return null;
730
731
  return ['bun', 'run', 'scripts/lint-packaging.ts'];
@@ -12,6 +12,11 @@
12
12
  * 3. Extracts exported definitions by duck-typing (has name/handler/input etc.)
13
13
  * 4. Feeds them into `validateDefinitions()`
14
14
  *
15
+ * A definition file whose `import()` rejects, and a `server.json` that exists
16
+ * but does not parse, are errors (`definition-import-failed`,
17
+ * `server-json-parse`): what they declare cannot be checked, so the run fails
18
+ * instead of passing without them. The remaining files are still linted.
19
+ *
15
20
  * Runtime-agnostic: works with bun, tsx, and Node.js (via ts-node/esm).
16
21
  *
17
22
  * Rule knobs come from the project's `devcheck.config.json` `lint` block, so one
@@ -20,7 +25,7 @@
20
25
  * @module scripts/lint-mcp
21
26
  */
22
27
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
23
- import { join, resolve } from 'node:path';
28
+ import { join, relative, resolve } from 'node:path';
24
29
  import { fileURLToPath } from 'node:url';
25
30
 
26
31
  // ---------------------------------------------------------------------------
@@ -100,19 +105,64 @@ function discoverFiles(): string[] {
100
105
  return [...new Set(files)];
101
106
  }
102
107
 
108
+ // ---------------------------------------------------------------------------
109
+ // Load failures
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /** Where the rule reference lives — the breadcrumb `validateDefinitions()` appends too. */
113
+ const SKILL_REFERENCE_PATH = 'framework-skills/api-linter/SKILL.md';
114
+
115
+ /** A file the CLI could not load, shaped like the diagnostics it is printed beside. */
116
+ interface LoadFailure {
117
+ message: string;
118
+ rule: 'definition-import-failed' | 'server-json-parse';
119
+ }
120
+
121
+ /**
122
+ * The text of a thrown value. An `AggregateError` keeps its causes in `errors`
123
+ * — Bun rejects a definition that fails to transpile with one whose own message
124
+ * only counts them ("2 errors building …") — so each cause is appended.
125
+ */
126
+ function describeError(err: unknown): string {
127
+ if (!(err instanceof Error)) return String(err);
128
+ const message = err.message || err.name;
129
+ if (!(err instanceof AggregateError) || err.errors.length === 0) return message;
130
+ return `${message} (${err.errors.map(describeError).join('; ')})`;
131
+ }
132
+
133
+ function loadFailure(rule: LoadFailure['rule'], file: string, err: unknown): LoadFailure {
134
+ const anchor = rule === 'server-json-parse' ? 'server-json-rules' : rule;
135
+ return {
136
+ rule,
137
+ message: `${file}: ${describeError(err)}\nSee: ${SKILL_REFERENCE_PATH}#${anchor}`,
138
+ };
139
+ }
140
+
103
141
  // ---------------------------------------------------------------------------
104
142
  // Main
105
143
  // ---------------------------------------------------------------------------
106
144
 
107
- /** Try to read and parse a JSON file. Returns undefined on failure. */
108
- function tryReadJson(path: string): unknown {
145
+ /** A parsed JSON file, or the error an existing one failed to read or parse with. */
146
+ type JsonRead = { ok: true; value: unknown } | { ok: false; error: unknown };
147
+
148
+ /** Reads and parses a JSON file. `undefined` when the file does not exist. */
149
+ function readJson(path: string): JsonRead | undefined {
150
+ if (!existsSync(path)) return;
109
151
  try {
110
- if (!existsSync(path)) return;
111
- return JSON.parse(readFileSync(path, 'utf-8'));
112
- } catch (err) {
113
- console.warn(`Warning: Failed to parse ${path}: ${err instanceof Error ? err.message : err}`);
152
+ return { ok: true, value: JSON.parse(readFileSync(path, 'utf-8')) };
153
+ } catch (error) {
154
+ return { ok: false, error };
155
+ }
156
+ }
157
+
158
+ /** Try to read and parse a JSON file. Returns undefined when absent, and warns when unparseable. */
159
+ function tryReadJson(path: string): unknown {
160
+ const read = readJson(path);
161
+ if (read?.ok === false) {
162
+ console.warn(`Warning: Failed to parse ${path}: ${describeError(read.error)}`);
114
163
  return;
115
164
  }
165
+ return read?.value;
116
166
  }
117
167
 
118
168
  /** The `lint` block of `devcheck.config.json`, as far as this script reads it. */
@@ -149,12 +199,17 @@ export function readLintOptions(configPath = resolve('devcheck.config.json')): L
149
199
 
150
200
  async function main(): Promise<void> {
151
201
  const files = discoverFiles();
202
+ const failures: LoadFailure[] = [];
152
203
 
153
204
  // Discover server.json and package.json at project root
154
- const serverJson = tryReadJson(resolve('server.json'));
205
+ const serverJsonRead = readJson(resolve('server.json'));
206
+ if (serverJsonRead?.ok === false) {
207
+ failures.push(loadFailure('server-json-parse', 'server.json', serverJsonRead.error));
208
+ }
209
+ const serverJson = serverJsonRead?.ok ? serverJsonRead.value : undefined;
155
210
  const packageJson = tryReadJson(resolve('package.json')) as { version?: string } | undefined;
156
211
 
157
- if (files.length === 0 && serverJson == null) {
212
+ if (files.length === 0 && serverJson == null && failures.length === 0) {
158
213
  console.log('No MCP definition files or server.json found. Skipping lint.');
159
214
  process.exit(0);
160
215
  }
@@ -162,36 +217,42 @@ async function main(): Promise<void> {
162
217
  const tools: unknown[] = [];
163
218
  const resources: unknown[] = [];
164
219
  const prompts: unknown[] = [];
220
+ let failedImports = 0;
165
221
 
166
222
  for (const file of files) {
223
+ let mod: Record<string, unknown>;
167
224
  try {
168
- const mod = await import(file);
169
- for (const exported of Object.values(mod)) {
170
- if (isToolLike(exported)) tools.push(exported);
171
- else if (isResourceLike(exported)) resources.push(exported);
172
- else if (isPromptLike(exported)) prompts.push(exported);
173
- }
225
+ mod = await import(file);
174
226
  } catch (err) {
175
- console.warn(
176
- `Warning: Failed to import ${file}: ${err instanceof Error ? err.message : err}`,
177
- );
227
+ failures.push(loadFailure('definition-import-failed', relative(process.cwd(), file), err));
228
+ failedImports++;
229
+ continue;
230
+ }
231
+ for (const exported of Object.values(mod)) {
232
+ if (isToolLike(exported)) tools.push(exported);
233
+ else if (isResourceLike(exported)) resources.push(exported);
234
+ else if (isPromptLike(exported)) prompts.push(exported);
178
235
  }
179
236
  }
180
237
 
181
238
  const defTotal = tools.length + resources.length + prompts.length;
182
- if (defTotal === 0 && serverJson == null) {
239
+ if (defTotal === 0 && serverJson == null && failures.length === 0) {
183
240
  console.log(`Scanned ${files.length} files but found no definitions. Skipping lint.`);
184
241
  process.exit(0);
185
242
  }
186
243
 
187
244
  const parts: string[] = [];
188
- if (defTotal > 0) {
245
+ if (defTotal > 0 || failedImports > 0) {
246
+ const fileCount =
247
+ failedImports === 0
248
+ ? `${files.length}`
249
+ : `${files.length - failedImports} of ${files.length}`;
189
250
  parts.push(
190
- `${tools.length} tool(s), ${resources.length} resource(s), ${prompts.length} prompt(s) from ${files.length} file(s)`,
251
+ `${tools.length} tool(s), ${resources.length} resource(s), ${prompts.length} prompt(s) from ${fileCount} file(s)`,
191
252
  );
192
253
  }
193
254
  if (serverJson != null) parts.push('server.json');
194
- console.log(`Linting ${parts.join(' + ')}...`);
255
+ if (parts.length > 0) console.log(`Linting ${parts.join(' + ')}...`);
195
256
 
196
257
  const report = validateDefinitions({
197
258
  tools,
@@ -202,14 +263,15 @@ async function main(): Promise<void> {
202
263
  ...readLintOptions(),
203
264
  });
204
265
 
266
+ const errors = [...failures, ...report.errors];
205
267
  for (const w of report.warnings) {
206
268
  console.warn(` ⚠ [${w.rule}] ${w.message}`);
207
269
  }
208
- for (const e of report.errors) {
270
+ for (const e of errors) {
209
271
  console.error(` ✗ [${e.rule}] ${e.message}`);
210
272
  }
211
273
 
212
- if (report.passed) {
274
+ if (errors.length === 0) {
213
275
  if (report.warnings.length > 0) {
214
276
  console.log(`\nPassed with ${report.warnings.length} warning(s).`);
215
277
  } else {
@@ -217,9 +279,7 @@ async function main(): Promise<void> {
217
279
  }
218
280
  process.exit(0);
219
281
  } else {
220
- console.error(
221
- `\nFailed: ${report.errors.length} error(s), ${report.warnings.length} warning(s).`,
222
- );
282
+ console.error(`\nFailed: ${errors.length} error(s), ${report.warnings.length} warning(s).`);
223
283
  process.exit(1);
224
284
  }
225
285
  }
@@ -65,6 +65,16 @@
65
65
  * so without the entry a release that bundles before publishing ships the
66
66
  * server and its production dependencies inside the npm tarball
67
67
  * (issue #469). Skipped when `manifest.json` or `files` is absent.
68
+ * 14. manifest.json version parity: `version` must equal `package.json`'s, the
69
+ * same rule check 10 applies to the plugin manifests — the bundle's install
70
+ * dialog shows it. Skipped when `manifest.json` or the package version is
71
+ * absent.
72
+ * 15. Dockerfile build platform: every stage that runs `bun run build` must
73
+ * start `FROM --platform=$BUILDPLATFORM`. Without it the non-native leg of
74
+ * a multi-arch `docker buildx` build runs under QEMU, where bun >= 1.4
75
+ * aborts inside the build and no image publishes for either architecture —
76
+ * and the image publishes last, after npm and the MCP Registry. Skipped
77
+ * when there is no `Dockerfile` or no stage builds.
68
78
  *
69
79
  * Every check skips cleanly when its input is absent — consumers who deleted
70
80
  * `manifest.json` for an HTTP-only deploy, or who haven't built a bundle,
@@ -103,6 +113,7 @@ interface Manifest {
103
113
  name?: string;
104
114
  server?: { mcp_config?: { args?: unknown[]; env?: Record<string, string> } };
105
115
  user_config?: Record<string, ManifestUserConfigEntry>;
116
+ version?: unknown;
106
117
  }
107
118
 
108
119
  const USER_CONFIG_REF = /^\$\{user_config\.([\w-]+)\}$/;
@@ -775,6 +786,49 @@ export function checkBundleExcludedFromFiles(files: unknown): string[] {
775
786
  ];
776
787
  }
777
788
 
789
+ /**
790
+ * Check 14: manifest.json `version` must equal `package.json`'s. Skipped when
791
+ * the package version is absent — the same fail-safe as checks 10 and 12.
792
+ */
793
+ export function checkManifestVersion(manifest: Manifest, packageVersion?: string): string[] {
794
+ if (!packageVersion || manifest.version === packageVersion) return [];
795
+ return [
796
+ manifest.version === undefined
797
+ ? `manifest.json has no "version" — must declare the package.json version "${packageVersion}"`
798
+ : `manifest.json "version" is "${String(manifest.version)}" — must equal the package.json version "${packageVersion}"`,
799
+ ];
800
+ }
801
+
802
+ const DOCKERFILE_FROM = /^\s*FROM\s/i;
803
+ const DOCKERFILE_BUILD_PLATFORM = /--platform=\$\{?BUILDPLATFORM\}?(?:\s|$)/;
804
+ const DOCKERFILE_BUILD_STEP = /\bbun run (?:re)?build\b/;
805
+
806
+ /**
807
+ * Check 15: every Dockerfile stage that runs `bun run build` must be pinned to
808
+ * the build platform. Stages are split at `FROM` lines; comment lines are
809
+ * ignored so a note mentioning the build does not count as one.
810
+ */
811
+ export function checkDockerfileBuildPlatform(dockerfile: string): string[] {
812
+ const stages: { from: string; line: number; builds: boolean }[] = [];
813
+ for (const [index, line] of dockerfile.split('\n').entries()) {
814
+ if (DOCKERFILE_FROM.test(line)) {
815
+ stages.push({ from: line.trim(), line: index + 1, builds: false });
816
+ } else if (!line.trimStart().startsWith('#') && DOCKERFILE_BUILD_STEP.test(line)) {
817
+ const stage = stages.at(-1);
818
+ if (stage) stage.builds = true;
819
+ }
820
+ }
821
+
822
+ return stages
823
+ .filter((stage) => stage.builds && !DOCKERFILE_BUILD_PLATFORM.test(stage.from))
824
+ .map(
825
+ (stage) =>
826
+ `Dockerfile:${stage.line} "${stage.from}" runs \`bun run build\` without --platform=$BUILDPLATFORM — ` +
827
+ `a multi-arch buildx build then runs it under QEMU, where bun aborts and no image publishes; ` +
828
+ `start the build stage with "FROM --platform=$BUILDPLATFORM" and copy dist/ into a separate runtime stage`,
829
+ );
830
+ }
831
+
778
832
  /** Read `packaging.pluginManifests` from devcheck.config.json; default on. */
779
833
  function pluginManifestsEnabled(): boolean {
780
834
  const cfg = tryReadJson<{ packaging?: { pluginManifests?: boolean } }>(
@@ -862,6 +916,7 @@ async function main(): Promise<void> {
862
916
  errors.push(...checkManifestIdentity(manifest, unscopedName));
863
917
  }
864
918
 
919
+ errors.push(...checkManifestVersion(manifest, pkg?.version));
865
920
  errors.push(...checkBundleExcludedFromFiles(pkg?.files));
866
921
  } else {
867
922
  notes.push('No manifest.json — skipping manifest/server.json alignment checks.');
@@ -911,6 +966,12 @@ async function main(): Promise<void> {
911
966
  }
912
967
  }
913
968
 
969
+ // ── Dockerfile build platform (check 15) ──
970
+ const dockerfilePath = resolve('Dockerfile');
971
+ if (existsSync(dockerfilePath)) {
972
+ errors.push(...checkDockerfileBuildPlatform(readFileSync(dockerfilePath, 'utf-8')));
973
+ }
974
+
914
975
  // ── README version badge (check 12) ──
915
976
  const readmePath = resolve('README.md');
916
977
  if (existsSync(readmePath)) {
@@ -18,6 +18,15 @@
18
18
  * The framework itself has no `manifest.json`/`.mcpb`, so the attach path is
19
19
  * skipped here but scaffolded servers that do have a manifest get the full flow.
20
20
  *
21
+ * Before any `gh` call it validates the tag — `--notes-from-tag` publishes the
22
+ * message verbatim as the release body, so a malformed tag becomes a malformed
23
+ * public release. `--check` runs only that validation, for use right after
24
+ * `git tag -a` and before the tag is pushed. The rules are the ones the
25
+ * `release-and-publish` skill states for the annotation: an annotated tag, a
26
+ * subject of at most 72 characters with no version and no `;`, flat bullets
27
+ * with no section headers, no signature block leaked into the body, and the
28
+ * `[CHANGELOG v<version>](…)` link as the final line.
29
+ *
21
30
  * @module scripts/release-github
22
31
  *
23
32
  * @example
@@ -25,6 +34,10 @@
25
34
  * // bun run release:github
26
35
  *
27
36
  * @example
37
+ * // Validate the tag annotation only (exit 1 on a violation, no gh calls):
38
+ * // bun run release:github -- --check
39
+ *
40
+ * @example
28
41
  * // Dry-run — print the command that would be executed without running it:
29
42
  * // bun run release:github -- --dry-run
30
43
  */
@@ -33,8 +46,86 @@ import { spawnSync } from 'node:child_process';
33
46
  import { existsSync, readFileSync } from 'node:fs';
34
47
  import { resolve } from 'node:path';
35
48
  import process from 'node:process';
49
+ import { fileURLToPath } from 'node:url';
36
50
 
37
51
  const DRY_RUN = process.argv.includes('--dry-run');
52
+ const CHECK_ONLY = process.argv.includes('--check');
53
+
54
+ /** A tag subject longer than this reads as a digest, not a release title. */
55
+ const MAX_SUBJECT_LENGTH = 72;
56
+
57
+ /**
58
+ * Section headers belong in the changelog entry, never in the tag body: a
59
+ * markdown heading, a Keep a Changelog section name, or any other line that is
60
+ * not a bullet and ends in a colon (`Dependency bumps:`, `Highlights:`).
61
+ */
62
+ const SECTION_HEADER =
63
+ /^(?:#{1,6}\s.*|(?:Added|Changed|Deprecated|Removed|Fixed|Security|Dependencies)\s*:?|[^\s\-*+[].*:)$/;
64
+
65
+ /** The parts of an annotated tag that become the GitHub Release title and body. */
66
+ export interface TagMessage {
67
+ body: string;
68
+ /** `git for-each-ref %(objecttype)` — `tag` for an annotated tag, `commit` for a lightweight one. */
69
+ objectType: string;
70
+ subject: string;
71
+ }
72
+
73
+ /**
74
+ * Validates a tag annotation against the release-body rules. Returns one
75
+ * message per violation; an empty array means the tag is publishable.
76
+ */
77
+ export function checkTagMessage(tag: TagMessage, version: string): string[] {
78
+ if (tag.objectType !== 'tag') {
79
+ return [
80
+ `v${version} is a lightweight tag — recreate it annotated: git tag -a v${version} -F <file>`,
81
+ ];
82
+ }
83
+
84
+ const errors: string[] = [];
85
+ const { subject } = tag;
86
+ if (subject.length > MAX_SUBJECT_LENGTH) {
87
+ errors.push(
88
+ `subject is ${subject.length} characters — keep it one short theme (≤${MAX_SUBJECT_LENGTH}); the bullets carry the digest`,
89
+ );
90
+ }
91
+ if (subject.includes(version)) {
92
+ errors.push(
93
+ `subject contains the version "${version}" — GitHub prepends "v${version}:" to the title`,
94
+ );
95
+ }
96
+ if (subject.includes(';')) {
97
+ errors.push('subject contains ";" — one theme, not a list of changes');
98
+ }
99
+
100
+ if (tag.body.includes('-----BEGIN')) {
101
+ errors.push(
102
+ 'body contains a signature block — the signature did not parse (usually --cleanup=verbatim); recreate the tag with --cleanup=whitespace before it is pushed',
103
+ );
104
+ }
105
+
106
+ const lines = tag.body
107
+ .split('\n')
108
+ .map((line) => line.trim())
109
+ .filter((line) => line.length > 0);
110
+ const headers = lines.filter((line) => SECTION_HEADER.test(line));
111
+ if (headers.length > 0) {
112
+ errors.push(
113
+ `body has section headers (${headers.map((h) => `"${h}"`).join(', ')}) — flat bullets only; sections belong in the changelog entry`,
114
+ );
115
+ }
116
+
117
+ const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
118
+ const changelogLink = new RegExp(
119
+ `^\\[CHANGELOG v${escaped}\\]\\(\\S+/changelog/\\d+\\.\\d+\\.x/${escaped}\\.md\\)`,
120
+ );
121
+ if (!changelogLink.test(lines.at(-1) ?? '')) {
122
+ errors.push(
123
+ `final line is not the changelog link — end the body with "[CHANGELOG v${version}](https://github.com/<OWNER>/<REPO>/blob/main/changelog/<major.minor>.x/${version}.md)"`,
124
+ );
125
+ }
126
+
127
+ return errors;
128
+ }
38
129
 
39
130
  // ── Helpers ───────────────────────────────────────────────────────────────────
40
131
 
@@ -107,15 +198,34 @@ function main(): void {
107
198
  if (!subject) {
108
199
  console.error(
109
200
  `Tag ${tag} not found locally or has no subject line. ` +
110
- `Create the annotated tag first: git tag -a ${tag} -m "..."`,
201
+ `Create the annotated tag first: git tag -a ${tag} -F <file>`,
111
202
  );
112
203
  process.exit(1);
113
204
  }
114
205
 
206
+ // 3. Validate the annotation — it becomes the public release body verbatim
207
+ const errors = checkTagMessage(
208
+ {
209
+ subject,
210
+ body: run('git', ['for-each-ref', `refs/tags/${tag}`, '--format=%(contents:body)']),
211
+ objectType: run('git', ['for-each-ref', `refs/tags/${tag}`, '--format=%(objecttype)']),
212
+ },
213
+ version,
214
+ );
215
+ if (errors.length > 0) {
216
+ console.error(`Tag ${tag} is not publishable:`);
217
+ for (const error of errors) console.error(` ✗ ${error}`);
218
+ process.exit(1);
219
+ }
220
+ if (CHECK_ONLY) {
221
+ console.log(`Tag ${tag} OK.`);
222
+ return;
223
+ }
224
+
115
225
  const title = `${tag}: ${subject}`;
116
226
  const hasMcpb = existsSync('manifest.json');
117
227
 
118
- // 3. Build the gh release create command
228
+ // 4. Build the gh release create command
119
229
  const createArgs = [
120
230
  'release',
121
231
  'create',
@@ -152,7 +262,7 @@ function main(): void {
152
262
  console.log(' asset: dist/*.mcpb');
153
263
  }
154
264
 
155
- // 4. Try to create the release
265
+ // 5. Try to create the release
156
266
  const createResult = gh(createArgs, { required: false });
157
267
 
158
268
  if (!createResult.startsWith('__ERROR__:')) {
@@ -170,7 +280,7 @@ function main(): void {
170
280
  process.exit(1);
171
281
  }
172
282
 
173
- // 5. Release already exists — repair: upload asset (if applicable) and set title.
283
+ // 6. Release already exists — repair: upload asset (if applicable) and set title.
174
284
  console.log(`Release ${tag} already exists. Repairing…`);
175
285
 
176
286
  if (hasMcpb) {
@@ -184,4 +294,6 @@ function main(): void {
184
294
  console.log(`Release ${tag} repaired.`);
185
295
  }
186
296
 
187
- main();
297
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
298
+ main();
299
+ }
@@ -3,6 +3,8 @@
3
3
  /.claude/
4
4
  /.agents/
5
5
  /framework-skills/
6
+ /docs/idea.md
7
+ /logs/
6
8
  /Dockerfile
7
9
  /bun.lock
8
10
  /bunfig.toml