@cyanheads/mcp-ts-core 0.13.8 → 0.13.10

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 (180) hide show
  1. package/AGENTS.md +51 -24
  2. package/CLAUDE.md +51 -24
  3. package/README.md +10 -10
  4. package/changelog/0.13.x/0.13.10.md +118 -0
  5. package/changelog/0.13.x/0.13.9.md +113 -0
  6. package/dist/config/index.d.ts +3 -0
  7. package/dist/config/index.d.ts.map +1 -1
  8. package/dist/config/index.js +31 -9
  9. package/dist/config/index.js.map +1 -1
  10. package/dist/core/app.d.ts +6 -3
  11. package/dist/core/app.d.ts.map +1 -1
  12. package/dist/core/app.js +21 -5
  13. package/dist/core/app.js.map +1 -1
  14. package/dist/core/context.d.ts +114 -21
  15. package/dist/core/context.d.ts.map +1 -1
  16. package/dist/core/context.js +40 -0
  17. package/dist/core/context.js.map +1 -1
  18. package/dist/core/index.d.ts +1 -1
  19. package/dist/core/index.d.ts.map +1 -1
  20. package/dist/core/index.js.map +1 -1
  21. package/dist/core/serverManifest.d.ts +6 -0
  22. package/dist/core/serverManifest.d.ts.map +1 -1
  23. package/dist/core/serverManifest.js +6 -0
  24. package/dist/core/serverManifest.js.map +1 -1
  25. package/dist/core/worker.d.ts +6 -0
  26. package/dist/core/worker.d.ts.map +1 -1
  27. package/dist/core/worker.js +1 -0
  28. package/dist/core/worker.js.map +1 -1
  29. package/dist/linter/rules/error-contract-rules.d.ts +3 -44
  30. package/dist/linter/rules/error-contract-rules.d.ts.map +1 -1
  31. package/dist/linter/rules/error-contract-rules.js +8 -144
  32. package/dist/linter/rules/error-contract-rules.js.map +1 -1
  33. package/dist/linter/rules/index.d.ts +1 -1
  34. package/dist/linter/rules/index.d.ts.map +1 -1
  35. package/dist/linter/rules/index.js +1 -1
  36. package/dist/linter/rules/index.js.map +1 -1
  37. package/dist/linter/rules/resource-rules.d.ts.map +1 -1
  38. package/dist/linter/rules/resource-rules.js +1 -2
  39. package/dist/linter/rules/resource-rules.js.map +1 -1
  40. package/dist/linter/rules/tool-rules.d.ts +2 -1
  41. package/dist/linter/rules/tool-rules.d.ts.map +1 -1
  42. package/dist/linter/rules/tool-rules.js +37 -3
  43. package/dist/linter/rules/tool-rules.js.map +1 -1
  44. package/dist/mcp-server/handlerContext.d.ts +26 -13
  45. package/dist/mcp-server/handlerContext.d.ts.map +1 -1
  46. package/dist/mcp-server/handlerContext.js +32 -17
  47. package/dist/mcp-server/handlerContext.js.map +1 -1
  48. package/dist/mcp-server/inputRequired.d.ts +133 -12
  49. package/dist/mcp-server/inputRequired.d.ts.map +1 -1
  50. package/dist/mcp-server/inputRequired.js +192 -20
  51. package/dist/mcp-server/inputRequired.js.map +1 -1
  52. package/dist/mcp-server/prompts/prompt-registration.d.ts +10 -2
  53. package/dist/mcp-server/prompts/prompt-registration.d.ts.map +1 -1
  54. package/dist/mcp-server/prompts/prompt-registration.js +49 -11
  55. package/dist/mcp-server/prompts/prompt-registration.js.map +1 -1
  56. package/dist/mcp-server/resources/resource-registration.d.ts +4 -2
  57. package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
  58. package/dist/mcp-server/resources/resource-registration.js +6 -4
  59. package/dist/mcp-server/resources/resource-registration.js.map +1 -1
  60. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts +4 -3
  61. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
  62. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +42 -14
  63. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
  64. package/dist/mcp-server/server.d.ts +9 -0
  65. package/dist/mcp-server/server.d.ts.map +1 -1
  66. package/dist/mcp-server/server.js +14 -13
  67. package/dist/mcp-server/server.js.map +1 -1
  68. package/dist/mcp-server/tools/tool-registration.d.ts +7 -3
  69. package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
  70. package/dist/mcp-server/tools/tool-registration.js +9 -5
  71. package/dist/mcp-server/tools/tool-registration.js.map +1 -1
  72. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts +163 -40
  73. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts.map +1 -1
  74. package/dist/mcp-server/tools/utils/inputPrevalidation.js +330 -114
  75. package/dist/mcp-server/tools/utils/inputPrevalidation.js.map +1 -1
  76. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +40 -19
  77. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  78. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +387 -130
  79. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  80. package/dist/mcp-server/transports/http/httpErrorHandler.d.ts.map +1 -1
  81. package/dist/mcp-server/transports/http/httpErrorHandler.js +7 -1
  82. package/dist/mcp-server/transports/http/httpErrorHandler.js.map +1 -1
  83. package/dist/mcp-server/transports/http/httpTransport.d.ts.map +1 -1
  84. package/dist/mcp-server/transports/http/httpTransport.js +65 -9
  85. package/dist/mcp-server/transports/http/httpTransport.js.map +1 -1
  86. package/dist/mcp-server/transports/stdio/stdioTransport.d.ts +9 -5
  87. package/dist/mcp-server/transports/stdio/stdioTransport.d.ts.map +1 -1
  88. package/dist/mcp-server/transports/stdio/stdioTransport.js +9 -5
  89. package/dist/mcp-server/transports/stdio/stdioTransport.js.map +1 -1
  90. package/dist/services/canvas/core/CanvasRegistry.d.ts +6 -2
  91. package/dist/services/canvas/core/CanvasRegistry.d.ts.map +1 -1
  92. package/dist/services/canvas/core/CanvasRegistry.js +7 -3
  93. package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
  94. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts +82 -18
  95. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts.map +1 -1
  96. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js +620 -328
  97. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js.map +1 -1
  98. package/dist/services/canvas/providers/duckdb/exportWriter.d.ts +11 -7
  99. package/dist/services/canvas/providers/duckdb/exportWriter.d.ts.map +1 -1
  100. package/dist/services/canvas/providers/duckdb/exportWriter.js +19 -16
  101. package/dist/services/canvas/providers/duckdb/exportWriter.js.map +1 -1
  102. package/dist/services/mirror/core/defineMirror.d.ts +1 -0
  103. package/dist/services/mirror/core/defineMirror.d.ts.map +1 -1
  104. package/dist/services/mirror/core/defineMirror.js +1 -0
  105. package/dist/services/mirror/core/defineMirror.js.map +1 -1
  106. package/dist/testing/index.d.ts +17 -2
  107. package/dist/testing/index.d.ts.map +1 -1
  108. package/dist/testing/index.js +21 -7
  109. package/dist/testing/index.js.map +1 -1
  110. package/dist/types-global/errors.d.ts +18 -15
  111. package/dist/types-global/errors.d.ts.map +1 -1
  112. package/dist/utils/index.d.ts +1 -1
  113. package/dist/utils/index.d.ts.map +1 -1
  114. package/dist/utils/index.js.map +1 -1
  115. package/dist/utils/internal/error-handler/errorHandler.d.ts +5 -4
  116. package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
  117. package/dist/utils/internal/error-handler/errorHandler.js +9 -7
  118. package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
  119. package/dist/utils/internal/error-handler/types.d.ts +3 -1
  120. package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
  121. package/dist/utils/internal/performance.d.ts +4 -2
  122. package/dist/utils/internal/performance.d.ts.map +1 -1
  123. package/dist/utils/internal/performance.js +8 -6
  124. package/dist/utils/internal/performance.js.map +1 -1
  125. package/dist/utils/internal/telemetryMessages.d.ts +0 -1
  126. package/dist/utils/internal/telemetryMessages.d.ts.map +1 -1
  127. package/dist/utils/internal/telemetryMessages.js +0 -1
  128. package/dist/utils/internal/telemetryMessages.js.map +1 -1
  129. package/dist/utils/network/pacer.d.ts +38 -5
  130. package/dist/utils/network/pacer.d.ts.map +1 -1
  131. package/dist/utils/network/pacer.js +87 -25
  132. package/dist/utils/network/pacer.js.map +1 -1
  133. package/dist/utils/telemetry/attributes.d.ts +10 -5
  134. package/dist/utils/telemetry/attributes.d.ts.map +1 -1
  135. package/dist/utils/telemetry/attributes.js +10 -5
  136. package/dist/utils/telemetry/attributes.js.map +1 -1
  137. package/framework-skills/add-app-tool/SKILL.md +3 -3
  138. package/framework-skills/add-export/SKILL.md +5 -16
  139. package/framework-skills/add-prompt/SKILL.md +7 -3
  140. package/framework-skills/add-resource/SKILL.md +7 -5
  141. package/framework-skills/add-service/SKILL.md +3 -12
  142. package/framework-skills/add-test/SKILL.md +6 -3
  143. package/framework-skills/add-tool/SKILL.md +40 -42
  144. package/framework-skills/api-auth/SKILL.md +2 -2
  145. package/framework-skills/api-canvas/SKILL.md +17 -8
  146. package/framework-skills/api-config/SKILL.md +5 -4
  147. package/framework-skills/api-context/SKILL.md +168 -42
  148. package/framework-skills/api-errors/SKILL.md +48 -51
  149. package/framework-skills/api-linter/SKILL.md +30 -35
  150. package/framework-skills/api-mirror/SKILL.md +2 -1
  151. package/framework-skills/api-telemetry/SKILL.md +14 -10
  152. package/framework-skills/api-testing/SKILL.md +43 -11
  153. package/framework-skills/api-utils/SKILL.md +2 -2
  154. package/framework-skills/api-workers/SKILL.md +3 -1
  155. package/framework-skills/design-mcp-server/SKILL.md +6 -6
  156. package/framework-skills/field-test/SKILL.md +5 -5
  157. package/framework-skills/git-wrapup/SKILL.md +8 -6
  158. package/framework-skills/orchestrations/SKILL.md +7 -6
  159. package/framework-skills/orchestrations/workflows/field-test-fix.md +9 -19
  160. package/framework-skills/orchestrations/workflows/fix-wrapup-release.md +7 -7
  161. package/framework-skills/orchestrations/workflows/greenfield-build.md +8 -5
  162. package/framework-skills/orchestrations/workflows/maintenance-release.md +8 -8
  163. package/framework-skills/polish-docs-meta/SKILL.md +4 -4
  164. package/framework-skills/release-and-publish/SKILL.md +8 -6
  165. package/framework-skills/release-pr-review/SKILL.md +38 -24
  166. package/framework-skills/report-issue-framework/SKILL.md +7 -5
  167. package/framework-skills/report-issue-local/SKILL.md +8 -6
  168. package/framework-skills/security-pass/SKILL.md +14 -13
  169. package/package.json +6 -5
  170. package/scripts/devcheck.ts +7 -6
  171. package/scripts/install-otel.ts +84 -0
  172. package/scripts/lint-mcp.ts +87 -27
  173. package/scripts/lint-packaging.ts +226 -4
  174. package/scripts/release-github.ts +117 -5
  175. package/templates/.env.example +2 -0
  176. package/templates/AGENTS.md +5 -4
  177. package/templates/CLAUDE.md +5 -4
  178. package/templates/Dockerfile +67 -50
  179. package/templates/_.mcpbignore +2 -0
  180. package/templates/src/mcp-server/tools/definitions/echo.tool.ts +3 -8
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.10",
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": [
@@ -16,6 +16,7 @@
16
16
  "scripts/clean-mcpb.ts",
17
17
  "scripts/clean.ts",
18
18
  "scripts/devcheck.ts",
19
+ "scripts/install-otel.ts",
19
20
  "scripts/lint-mcp.ts",
20
21
  "scripts/lint-packaging.ts",
21
22
  "scripts/list-skills.ts",
@@ -198,10 +199,10 @@
198
199
  "devDependencies": {
199
200
  "@biomejs/biome": "2.5.14",
200
201
  "@cloudflare/vitest-pool-workers": "^0.22.0",
201
- "@cloudflare/workers-types": "5.20260922.1",
202
+ "@cloudflare/workers-types": "5.20260924.1",
202
203
  "@duckdb/node-api": "^1.5.5-r.5",
203
204
  "@hono/otel": "^1.1.2",
204
- "@modelcontextprotocol/client": "^2.0.0",
205
+ "@modelcontextprotocol/client": "^2.1.0",
205
206
  "@opentelemetry/api-logs": "^0.222.0",
206
207
  "@opentelemetry/exporter-logs-otlp-http": "^0.222.0",
207
208
  "@opentelemetry/exporter-metrics-otlp-http": "^0.222.0",
@@ -290,9 +291,9 @@
290
291
  },
291
292
  "dependencies": {
292
293
  "@hono/node-server": "^2.1.1",
293
- "@modelcontextprotocol/server": "^2.0.0",
294
+ "@modelcontextprotocol/server": "^2.1.0",
294
295
  "@opentelemetry/api": "^1.9.1",
295
- "hono": "^4.13.8",
296
+ "hono": "^4.13.9",
296
297
  "jose": "^6.2.12",
297
298
  "pino": "^10.3.1",
298
299
  "zod": "^4.6.5"
@@ -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'];
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * @fileoverview Installs the OpenTelemetry packages the framework loads at
4
+ * runtime into a production dependency tree — the Dockerfile's OTel step.
5
+ *
6
+ * The list is every `peerDependencies` entry named `@opentelemetry/*`, plus
7
+ * `@hono/otel`, in `@cyanheads/mcp-ts-core/package.json` resolved from the
8
+ * project root: the installed framework in a server, the package itself (by
9
+ * self-reference) in the framework. Each package moves into the project's
10
+ * `dependencies` at its declared peer range, out of `peerDependencies`,
11
+ * `peerDependenciesMeta`, and `devDependencies`, so `--omit=peer` keeps it
12
+ * while every other optional peer stays out. Then `bun install --omit=dev
13
+ * --omit=peer --ignore-scripts` runs with every argument given to this script
14
+ * appended, under the project's `bunfig.toml`, and its exit code is this
15
+ * script's. Not `--production`: it implies `--frozen-lockfile`, which fails
16
+ * once the manifest gains packages the lockfile lacks.
17
+ *
18
+ * @example
19
+ * // In a Dockerfile stage on $BUILDPLATFORM, cross-installing for the target:
20
+ * // bun scripts/install-otel.ts --os=linux --cpu=x64
21
+ * @module scripts/install-otel
22
+ */
23
+ import { spawnSync } from 'node:child_process';
24
+ import { readFileSync, writeFileSync } from 'node:fs';
25
+ import { createRequire } from 'node:module';
26
+ import { join, resolve } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ interface Manifest {
30
+ dependencies?: Record<string, string>;
31
+ devDependencies?: Record<string, string>;
32
+ peerDependencies?: Record<string, string>;
33
+ peerDependenciesMeta?: Record<string, unknown>;
34
+ }
35
+
36
+ /** The OTel optional peers a framework manifest declares, keyed by name, at their declared ranges. */
37
+ export function otelPeers(framework: Manifest): Record<string, string> {
38
+ return Object.fromEntries(
39
+ Object.entries(framework.peerDependencies ?? {}).filter(
40
+ ([name]) => name.startsWith('@opentelemetry/') || name === '@hono/otel',
41
+ ),
42
+ );
43
+ }
44
+
45
+ function main(args: string[]): number {
46
+ const manifestPath = join(process.cwd(), 'package.json');
47
+ const frameworkPath = createRequire(manifestPath).resolve('@cyanheads/mcp-ts-core/package.json');
48
+ const peers = otelPeers(JSON.parse(readFileSync(frameworkPath, 'utf8')) as Manifest);
49
+ const names = Object.keys(peers);
50
+ if (names.length === 0) {
51
+ console.error(
52
+ `install-otel: ${frameworkPath} declares no @opentelemetry/* or @hono/otel peerDependencies — nothing to install.`,
53
+ );
54
+ return 1;
55
+ }
56
+
57
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Manifest;
58
+ manifest.dependencies = { ...manifest.dependencies, ...peers };
59
+ for (const name of names) {
60
+ delete manifest.peerDependencies?.[name];
61
+ delete manifest.peerDependenciesMeta?.[name];
62
+ delete manifest.devDependencies?.[name];
63
+ }
64
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
65
+ console.log(
66
+ `install-otel: ${names.length} OpenTelemetry packages from ${frameworkPath}: ` +
67
+ names.map((name) => `${name}@${peers[name]}`).join(' '),
68
+ );
69
+
70
+ const install = spawnSync(
71
+ 'bun',
72
+ ['install', '--omit=dev', '--omit=peer', '--ignore-scripts', ...args],
73
+ { stdio: 'inherit' },
74
+ );
75
+ if (install.error) {
76
+ console.error(`install-otel: could not run bun install: ${install.error.message}`);
77
+ return 1;
78
+ }
79
+ return install.status ?? 1;
80
+ }
81
+
82
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
83
+ process.exit(main(process.argv.slice(2)));
84
+ }
@@ -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,21 @@
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: a stage that does not start
73
+ * `FROM --platform=$BUILDPLATFORM` must not run JavaScript while it
74
+ * builds — no `RUN` invoking `bun` for anything but `install`/`add`
75
+ * (`bun run build`, `bun -e`, a script, `bunx`), and no `bun install`/
76
+ * `bun add` once `bunfig.toml` is in the stage, since its security scanner
77
+ * runs as a Bun program. The non-native leg of a multi-arch `docker buildx`
78
+ * build runs such a stage under QEMU, where bun >= 1.4 aborts and no image
79
+ * publishes for either architecture — and the image publishes last, after
80
+ * npm and the MCP Registry. `HEALTHCHECK`/`CMD`/`ENTRYPOINT` run at
81
+ * container start and never count. One error per stage, naming each
82
+ * offending line (issue #575). Skipped when there is no `Dockerfile`.
68
83
  *
69
84
  * Every check skips cleanly when its input is absent — consumers who deleted
70
85
  * `manifest.json` for an HTTP-only deploy, or who haven't built a bundle,
@@ -103,6 +118,7 @@ interface Manifest {
103
118
  name?: string;
104
119
  server?: { mcp_config?: { args?: unknown[]; env?: Record<string, string> } };
105
120
  user_config?: Record<string, ManifestUserConfigEntry>;
121
+ version?: unknown;
106
122
  }
107
123
 
108
124
  const USER_CONFIG_REF = /^\$\{user_config\.([\w-]+)\}$/;
@@ -172,10 +188,10 @@ function tryReadJson<T>(path: string): T | undefined {
172
188
  * to evaluate which paths survive the ignore rules. Returns an array of error
173
189
  * strings; empty means all checks passed.
174
190
  *
175
- * **Context note:** this guard runs inside the scaffolded server project, not
176
- * inside mcp-ts-core itself. `ignore` is listed in `templates/package.json`
177
- * devDependencies (`^7.0.5`) and is therefore available in the server's
178
- * `node_modules` when `bun run lint:packaging` is invoked there.
191
+ * **Context note:** `ignore` is a devDependency of the framework and of every
192
+ * scaffold (`templates/package.json`), so it resolves wherever
193
+ * `bun run lint:packaging` runs against an `.mcpbignore` — a server project, or
194
+ * mcp-ts-core itself. Where it cannot load, the guard is skipped.
179
195
  */
180
196
  interface IgnoreMatcher {
181
197
  add(patterns: string[]): IgnoreMatcher;
@@ -775,6 +791,205 @@ export function checkBundleExcludedFromFiles(files: unknown): string[] {
775
791
  ];
776
792
  }
777
793
 
794
+ /**
795
+ * Check 14: manifest.json `version` must equal `package.json`'s. Skipped when
796
+ * the package version is absent — the same fail-safe as checks 10 and 12.
797
+ */
798
+ export function checkManifestVersion(manifest: Manifest, packageVersion?: string): string[] {
799
+ if (!packageVersion || manifest.version === packageVersion) return [];
800
+ return [
801
+ manifest.version === undefined
802
+ ? `manifest.json has no "version" — must declare the package.json version "${packageVersion}"`
803
+ : `manifest.json "version" is "${String(manifest.version)}" — must equal the package.json version "${packageVersion}"`,
804
+ ];
805
+ }
806
+
807
+ /** One Dockerfile instruction: continuations joined, a `RUN` heredoc body folded in. */
808
+ interface DockerInstruction {
809
+ args: string;
810
+ keyword: string;
811
+ /** 1-based line the instruction starts on. */
812
+ line: number;
813
+ text: string;
814
+ }
815
+
816
+ const DOCKERFILE_SKIPPED_LINE = /^\s*(?:#|$)/;
817
+ const DOCKERFILE_CONTINUATION = /\\\s*$/;
818
+ const DOCKERFILE_HEREDOC = /<<-?(["']?)([A-Za-z_]\w*)\1/g;
819
+
820
+ /**
821
+ * Splits a Dockerfile into instructions the way BuildKit reads it: a trailing
822
+ * `\` continues onto the next line, comment and blank lines inside a
823
+ * continuation are dropped, and a `RUN` heredoc's body belongs to its `RUN`.
824
+ */
825
+ function dockerInstructions(dockerfile: string): DockerInstruction[] {
826
+ const lines = dockerfile.split(/\r?\n/);
827
+ const instructions: DockerInstruction[] = [];
828
+ for (let i = 0; i < lines.length; i++) {
829
+ if (DOCKERFILE_SKIPPED_LINE.test(lines[i] ?? '')) continue;
830
+ const line = i + 1;
831
+ let text = (lines[i] ?? '').trim();
832
+ while (DOCKERFILE_CONTINUATION.test(text) && i + 1 < lines.length) {
833
+ text = text.replace(DOCKERFILE_CONTINUATION, ' ');
834
+ do i++;
835
+ while (i < lines.length && DOCKERFILE_SKIPPED_LINE.test(lines[i] ?? ''));
836
+ text += (lines[i] ?? '').trim();
837
+ }
838
+ const keyword = (text.split(/\s/, 1)[0] ?? '').toUpperCase();
839
+ if (keyword === 'RUN') {
840
+ for (const [, , delimiter] of text.matchAll(DOCKERFILE_HEREDOC)) {
841
+ while (i + 1 < lines.length && (lines[++i] ?? '').trim() !== delimiter) {
842
+ text += `\n${lines[i]}`;
843
+ }
844
+ }
845
+ }
846
+ instructions.push({ keyword, args: text.slice(keyword.length).trim(), line, text });
847
+ }
848
+ return instructions;
849
+ }
850
+
851
+ /** Words that may precede the command itself in a simple shell command. */
852
+ const SHELL_COMMAND_PREFIX = new Set([
853
+ '!',
854
+ 'command',
855
+ 'do',
856
+ 'elif',
857
+ 'else',
858
+ 'env',
859
+ 'exec',
860
+ 'if',
861
+ 'then',
862
+ 'time',
863
+ 'until',
864
+ 'while',
865
+ ]);
866
+
867
+ /** A `bun`/`bunx` invocation in a `RUN`: the command word and the words after it. */
868
+ interface BunInvocation {
869
+ args: string[];
870
+ command: 'bun' | 'bunx';
871
+ }
872
+
873
+ function bunCommand(word: string | undefined): BunInvocation['command'] | undefined {
874
+ const name = word?.split('/').pop();
875
+ return name === 'bun' || name === 'bunx' ? name : undefined;
876
+ }
877
+
878
+ /**
879
+ * The `bun`/`bunx` invocations a `RUN` instruction's command makes, read from
880
+ * command position only — `chown bun:bun`, `/root/.bun/`, and a quoted
881
+ * mention are not invocations. Quoted strings are opaque, so a `bun -e '…'`
882
+ * program never splits into commands of its own.
883
+ */
884
+ function bunInvocations(runArgs: string): BunInvocation[] {
885
+ const command = runArgs.replace(/^(?:--[\w-]+=\S+\s+)*/, '');
886
+ if (command.startsWith('[')) {
887
+ try {
888
+ const argv: unknown = JSON.parse(command);
889
+ if (Array.isArray(argv)) {
890
+ const name = bunCommand(String(argv[0]));
891
+ return name ? [{ command: name, args: argv.slice(1).map(String) }] : [];
892
+ }
893
+ } catch {
894
+ // Not a JSON exec form: fall through and read it as shell.
895
+ }
896
+ }
897
+ return command
898
+ .replace(/'[^']*'|"(?:\\.|[^"\\])*"/g, "'…'")
899
+ .split(/&&|\|\||[;|&(){}`\n]/)
900
+ .flatMap((segment) => {
901
+ const words = segment.trim().split(/\s+/);
902
+ const start = words.findIndex(
903
+ (word) => !SHELL_COMMAND_PREFIX.has(word) && !/^[A-Za-z_]\w*=/.test(word),
904
+ );
905
+ const name = bunCommand(words[start]);
906
+ return name ? [{ command: name, args: words.slice(start + 1) }] : [];
907
+ });
908
+ }
909
+
910
+ const DOCKERFILE_BUILD_PLATFORM = /--platform=\$\{?BUILDPLATFORM\}?(?:\s|$)/;
911
+ const BUN_INSTALL_SUBCOMMANDS = new Set(['install', 'i', 'add', 'a']);
912
+ const BUNFIG_SOURCE = /(?:^|[\s/"'[,])bunfig\.toml(?=[\s"',\]]|$)/;
913
+
914
+ /** Whether a `COPY`/`ADD` brings `bunfig.toml` into the stage, by name or with the whole context. */
915
+ function copiesBunfig(args: string): boolean {
916
+ if (BUNFIG_SOURCE.test(args)) return true;
917
+ const words = args.split(/\s+/);
918
+ if (words.some((word) => word.startsWith('--from='))) return false;
919
+ const sources = words.filter((word) => !word.startsWith('--')).slice(0, -1);
920
+ return sources.some((source) => source === '.' || source === './');
921
+ }
922
+
923
+ /**
924
+ * Check 15: a Dockerfile stage built for the target platform must not run
925
+ * JavaScript while it builds. A multi-arch `docker buildx` build runs every
926
+ * such stage under QEMU on the non-native leg, where bun >= 1.4 aborts, and the
927
+ * image publishes last — after npm and the MCP Registry. A stage counts as
928
+ * running JavaScript when a `RUN` invokes `bun` for anything but
929
+ * `install`/`add` (`bun run build`, `bun -e`, a script, `bunx`), or runs
930
+ * `bun install`/`bun add` once `bunfig.toml` is in the stage, since its
931
+ * `[install.security]` scanner runs as a Bun program. A stage whose `FROM`
932
+ * carries `--platform=$BUILDPLATFORM` is exempt, and so are `HEALTHCHECK`,
933
+ * `CMD`, and `ENTRYPOINT`, which run at container start on the real target.
934
+ * One error per stage, naming each offending line.
935
+ */
936
+ export function checkDockerfileBuildPlatform(dockerfile: string): string[] {
937
+ const stages: {
938
+ alias: string | undefined;
939
+ bunfig: boolean;
940
+ findings: string[];
941
+ from: DockerInstruction;
942
+ pinned: boolean;
943
+ }[] = [];
944
+
945
+ for (const instruction of dockerInstructions(dockerfile)) {
946
+ if (instruction.keyword === 'FROM') {
947
+ const [image = '', , alias] = instruction.args
948
+ .toLowerCase()
949
+ .split(/\s+/)
950
+ .filter((word) => !word.startsWith('--'));
951
+ stages.push({
952
+ alias,
953
+ // A stage built FROM an earlier one starts with that stage's files.
954
+ bunfig: stages.find((earlier) => earlier.alias === image)?.bunfig ?? false,
955
+ findings: [],
956
+ from: instruction,
957
+ pinned: DOCKERFILE_BUILD_PLATFORM.test(instruction.args),
958
+ });
959
+ continue;
960
+ }
961
+ const stage = stages.at(-1);
962
+ if (!stage) continue;
963
+ if (instruction.keyword === 'COPY' || instruction.keyword === 'ADD') {
964
+ stage.bunfig ||= copiesBunfig(instruction.args);
965
+ } else if (instruction.keyword === 'RUN' && !stage.pinned) {
966
+ const finding = bunInvocations(instruction.args)
967
+ .map(({ command, args }) => {
968
+ const subcommand = args.find((arg) => !arg.startsWith('-'));
969
+ if (command === 'bun' && BUN_INSTALL_SUBCOMMANDS.has(subcommand ?? '')) {
970
+ return stage.bunfig
971
+ ? `\`bun ${subcommand}\` after bunfig.toml is in the stage, which starts its security scanner as a Bun program`
972
+ : undefined;
973
+ }
974
+ return `\`${[command, args[0]].filter(Boolean).join(' ')}\``;
975
+ })
976
+ .find(Boolean);
977
+ if (finding) stage.findings.push(`Dockerfile:${instruction.line} runs ${finding}`);
978
+ }
979
+ }
980
+
981
+ return stages
982
+ .filter((stage) => stage.findings.length > 0)
983
+ .map(
984
+ ({ from, findings }) =>
985
+ `Dockerfile:${from.line} "${from.text}" builds for the target platform but runs JavaScript while ` +
986
+ `building — ${findings.join('; ')}. A multi-arch buildx build runs that under QEMU on the non-native ` +
987
+ `leg, where bun aborts and no image publishes. Move these steps into a stage that starts ` +
988
+ `"FROM --platform=$BUILDPLATFORM" (cross-install with \`bun install --os=<os> --cpu=<x64|arm64>\`) and ` +
989
+ `copy their output (dist/, node_modules/) into this stage`,
990
+ );
991
+ }
992
+
778
993
  /** Read `packaging.pluginManifests` from devcheck.config.json; default on. */
779
994
  function pluginManifestsEnabled(): boolean {
780
995
  const cfg = tryReadJson<{ packaging?: { pluginManifests?: boolean } }>(
@@ -862,6 +1077,7 @@ async function main(): Promise<void> {
862
1077
  errors.push(...checkManifestIdentity(manifest, unscopedName));
863
1078
  }
864
1079
 
1080
+ errors.push(...checkManifestVersion(manifest, pkg?.version));
865
1081
  errors.push(...checkBundleExcludedFromFiles(pkg?.files));
866
1082
  } else {
867
1083
  notes.push('No manifest.json — skipping manifest/server.json alignment checks.');
@@ -911,6 +1127,12 @@ async function main(): Promise<void> {
911
1127
  }
912
1128
  }
913
1129
 
1130
+ // ── Dockerfile build platform (check 15) ──
1131
+ const dockerfilePath = resolve('Dockerfile');
1132
+ if (existsSync(dockerfilePath)) {
1133
+ errors.push(...checkDockerfileBuildPlatform(readFileSync(dockerfilePath, 'utf-8')));
1134
+ }
1135
+
914
1136
  // ── README version badge (check 12) ──
915
1137
  const readmePath = resolve('README.md');
916
1138
  if (existsSync(readmePath)) {