@cyanheads/mcp-ts-core 0.10.4 → 0.10.6

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 (71) hide show
  1. package/AGENTS.md +6 -2
  2. package/CLAUDE.md +6 -2
  3. package/README.md +1 -1
  4. package/changelog/0.10.x/0.10.4.md +1 -1
  5. package/changelog/0.10.x/0.10.5.md +60 -0
  6. package/changelog/0.10.x/0.10.6.md +37 -0
  7. package/dist/core/app.d.ts.map +1 -1
  8. package/dist/core/app.js +5 -8
  9. package/dist/core/app.js.map +1 -1
  10. package/dist/core/worker.d.ts.map +1 -1
  11. package/dist/core/worker.js +0 -2
  12. package/dist/core/worker.js.map +1 -1
  13. package/dist/linter/rules/enrichment-rules.js +1 -1
  14. package/dist/linter/rules/enrichment-rules.js.map +1 -1
  15. package/dist/logs/combined.log +8 -0
  16. package/dist/logs/error.log +4 -0
  17. package/dist/logs/interactions.log +0 -0
  18. package/dist/services/canvas/core/CanvasRegistry.d.ts +6 -0
  19. package/dist/services/canvas/core/CanvasRegistry.d.ts.map +1 -1
  20. package/dist/services/canvas/core/CanvasRegistry.js +11 -0
  21. package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
  22. package/dist/services/mirror/sqlite/sqliteMirrorStore.d.ts.map +1 -1
  23. package/dist/services/mirror/sqlite/sqliteMirrorStore.js +6 -1
  24. package/dist/services/mirror/sqlite/sqliteMirrorStore.js.map +1 -1
  25. package/dist/services/mirror/types.d.ts +4 -2
  26. package/dist/services/mirror/types.d.ts.map +1 -1
  27. package/dist/services/speech/providers/elevenlabs.provider.d.ts +2 -2
  28. package/dist/services/speech/providers/elevenlabs.provider.js +3 -3
  29. package/dist/services/speech/providers/elevenlabs.provider.js.map +1 -1
  30. package/dist/services/speech/providers/whisper.provider.d.ts +1 -1
  31. package/dist/services/speech/providers/whisper.provider.js +3 -3
  32. package/dist/services/speech/providers/whisper.provider.js.map +1 -1
  33. package/dist/services/speech/types.d.ts +4 -4
  34. package/dist/services/speech/types.d.ts.map +1 -1
  35. package/dist/storage/providers/cloudflare/r2Provider.d.ts.map +1 -1
  36. package/dist/storage/providers/cloudflare/r2Provider.js +9 -2
  37. package/dist/storage/providers/cloudflare/r2Provider.js.map +1 -1
  38. package/dist/testing/vitest.d.ts +76 -0
  39. package/dist/testing/vitest.d.ts.map +1 -0
  40. package/dist/testing/vitest.js +70 -0
  41. package/dist/testing/vitest.js.map +1 -0
  42. package/dist/utils/internal/encoding.d.ts.map +1 -1
  43. package/dist/utils/internal/encoding.js +6 -17
  44. package/dist/utils/internal/encoding.js.map +1 -1
  45. package/dist/utils/internal/performance.d.ts +17 -27
  46. package/dist/utils/internal/performance.d.ts.map +1 -1
  47. package/dist/utils/internal/performance.js +23 -45
  48. package/dist/utils/internal/performance.js.map +1 -1
  49. package/dist/utils/network/fetchWithTimeout.d.ts.map +1 -1
  50. package/dist/utils/network/fetchWithTimeout.js +6 -13
  51. package/dist/utils/network/fetchWithTimeout.js.map +1 -1
  52. package/dist/utils/network/retry.js +11 -9
  53. package/dist/utils/network/retry.js.map +1 -1
  54. package/dist/utils/security/rateLimiter.d.ts +5 -0
  55. package/dist/utils/security/rateLimiter.d.ts.map +1 -1
  56. package/dist/utils/security/rateLimiter.js +7 -0
  57. package/dist/utils/security/rateLimiter.js.map +1 -1
  58. package/package.json +22 -11
  59. package/scripts/clean-mcpb.ts +118 -0
  60. package/scripts/lint-packaging.ts +312 -66
  61. package/skills/api-testing/SKILL.md +47 -1
  62. package/skills/api-workers/SKILL.md +97 -1
  63. package/skills/orchestrations/SKILL.md +1 -1
  64. package/skills/orchestrations/workflows/fix-wrapup-release.md +9 -5
  65. package/skills/polish-docs-meta/SKILL.md +4 -2
  66. package/templates/AGENTS.md +2 -2
  67. package/templates/CLAUDE.md +2 -2
  68. package/templates/manifest.json +0 -1
  69. package/templates/package.json +1 -1
  70. package/templates/src/index.ts +2 -0
  71. package/templates/tests/tools/echo.tool.test.ts +21 -0
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @fileoverview Post-pack MCPB bundle cleaner. Runs after `mcpb pack` (wired
4
+ * into the `bundle` package script) to remove bundle content the pack step
5
+ * cannot exclude:
6
+ *
7
+ * 1. `mcpb clean <bundle>` — official dev-dependency prune + manifest
8
+ * validation (a measured real-world bundle dropped 61 MB → 12.9 MB).
9
+ * 2. Exact-name strip of agent-doc entries nested under `node_modules/` —
10
+ * dependency-shipped `skills/`, `.claude/`, `.agents/` trees and stray
11
+ * `SKILL.md` files. Root-anchored `.mcpbignore` patterns cannot reach
12
+ * these by design (issues #146/#207); this is issue #230.
13
+ * 3. Re-list and assert zero matching entries remain.
14
+ *
15
+ * Entry names are passed to `zip -d` with `-nw` (no-wildcard) so they match
16
+ * literally — bracketed runtime filenames like `pages/[slug].js` exist in
17
+ * real packages and must not glob. Bundles are unsigned in this flow; if
18
+ * `mcpb sign` is ever adopted, this script must run before signing.
19
+ *
20
+ * Usage: `bun run scripts/clean-mcpb.ts dist/<name>.mcpb`
21
+ *
22
+ * @module scripts/clean-mcpb
23
+ */
24
+ import { execFileSync } from 'node:child_process';
25
+ import { existsSync, statSync } from 'node:fs';
26
+ import { resolve } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ /**
30
+ * Agent-doc entries under `node_modules/` that must not ship in a bundle.
31
+ * KEEP IN SYNC with `AGENT_DOC_ENTRY` in `scripts/lint-packaging.ts`
32
+ * (post-bundle content check) — a unit test asserts the two are identical.
33
+ */
34
+ export const AGENT_DOC_ENTRY =
35
+ /^node_modules\/.*(?:\/skills\/|\/\.claude\/|\/\.agents\/|\/SKILL\.md$)/;
36
+
37
+ /** Filter a bundle entry listing down to the agent-doc entries to strip. */
38
+ export function filterAgentDocEntries(entries: string[]): string[] {
39
+ return entries.filter((entry) => AGENT_DOC_ENTRY.test(entry));
40
+ }
41
+
42
+ /** Listing a 12k-entry bundle exceeds execFileSync's 1 MB default buffer. */
43
+ const MAX_LIST_BUFFER = 64 * 1024 * 1024;
44
+
45
+ /** `zip -d` argv batch size — stays clear of ARG_MAX with long entry names. */
46
+ const DELETE_BATCH = 200;
47
+
48
+ function listEntries(bundle: string): string[] {
49
+ return execFileSync('unzip', ['-Z1', bundle], { encoding: 'utf-8', maxBuffer: MAX_LIST_BUFFER })
50
+ .split('\n')
51
+ .filter((line) => line.length > 0);
52
+ }
53
+
54
+ function run(cmd: string, args: string[]): void {
55
+ execFileSync(cmd, args, { stdio: 'inherit' });
56
+ }
57
+
58
+ function mb(bytes: number): string {
59
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
60
+ }
61
+
62
+ function main(): void {
63
+ const bundleArg = process.argv[2];
64
+ if (!bundleArg) {
65
+ console.error('Usage: clean-mcpb.ts <bundle.mcpb> — run after `mcpb pack`.');
66
+ process.exit(1);
67
+ }
68
+ const bundle = resolve(bundleArg);
69
+ if (!existsSync(bundle)) {
70
+ console.error(`No bundle at ${bundle} — run \`mcpb pack\` first (see the \`bundle\` script).`);
71
+ process.exit(1);
72
+ }
73
+
74
+ const sizeBefore = statSync(bundle).size;
75
+
76
+ // 1. Official prune: removes dev dependencies, validates the manifest.
77
+ try {
78
+ run('npx', ['-y', '@anthropic-ai/mcpb', 'clean', bundle]);
79
+ } catch {
80
+ console.error('✗ `mcpb clean` failed — bundle left as packed.');
81
+ process.exit(1);
82
+ }
83
+
84
+ // 2. Exact-name strip of dependency-shipped agent docs.
85
+ let doomed: string[] = [];
86
+ try {
87
+ doomed = filterAgentDocEntries(listEntries(bundle));
88
+ for (let i = 0; i < doomed.length; i += DELETE_BATCH) {
89
+ run('zip', ['-q', '-d', '-nw', bundle, ...doomed.slice(i, i + DELETE_BATCH)]);
90
+ }
91
+ } catch (err) {
92
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
93
+ console.error(
94
+ '✗ Info-ZIP `zip`/`unzip` not found on PATH — required for the agent-doc strip.',
95
+ );
96
+ } else {
97
+ console.error(`✗ Agent-doc strip failed: ${err instanceof Error ? err.message : err}`);
98
+ }
99
+ process.exit(1);
100
+ }
101
+
102
+ // 3. Verify: zero matching entries remain.
103
+ const remaining = filterAgentDocEntries(listEntries(bundle));
104
+ if (remaining.length > 0) {
105
+ console.error(`✗ ${remaining.length} agent-doc entries still present after strip, e.g.:`);
106
+ for (const entry of remaining.slice(0, 5)) console.error(` ${entry}`);
107
+ process.exit(1);
108
+ }
109
+
110
+ const sizeAfter = statSync(bundle).size;
111
+ console.log(
112
+ `Bundle cleaned: ${mb(sizeBefore)} → ${mb(sizeAfter)} (${doomed.length} agent-doc entries stripped).`,
113
+ );
114
+ }
115
+
116
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
117
+ main();
118
+ }
@@ -2,7 +2,8 @@
2
2
  /**
3
3
  * @fileoverview MCPB packaging linter — validates env var alignment between
4
4
  * `manifest.json` (MCPB bundle install UX) and `server.json` (MCP Registry
5
- * discovery) for stdio packages, and guards against bundle-content mistakes.
5
+ * discovery) for stdio packages, and guards against bundle-content and
6
+ * identity mistakes.
6
7
  *
7
8
  * Used by devcheck and as a standalone script: `bun run lint:packaging` /
8
9
  * `npm run lint:packaging`.
@@ -22,17 +23,26 @@
22
23
  * `node_modules/x/skills/` (runtime path bypass, issues #172/#207).
23
24
  * 7. Bundle-content guard: `.mcpbignore` patterns must not strip critical
24
25
  * runtime package paths (e.g. `node_modules/@opentelemetry/api/build/src/`).
26
+ * 8. Post-bundle content: a built `.mcpb` under `dist/` must contain zero
27
+ * `node_modules/**` agent-doc entries (dependency-shipped `skills/`,
28
+ * `.claude/`, `.agents/`, `SKILL.md`) — unreachable by root-anchored
29
+ * `.mcpbignore` patterns; `scripts/clean-mcpb.ts` strips them at bundle
30
+ * time (issue #230).
31
+ * 9. Identity: `name`/`title` literals in `createApp()` /
32
+ * `createWorkerHandler()` (src/index.ts, src/worker.ts) and manifest
33
+ * `display_name` must equal the unscoped package name; a partial
34
+ * `name`/`title` pair warns without failing (issue #231).
25
35
  *
26
- * Checks 1–2 run with `manifest.json` alone; 3–4 require `server.json`.
27
- * Checks 5–7 run when `.mcpbignore` is present (silently skip otherwise).
28
- *
29
- * Skips cleanly when `manifest.json` is absent — consumers who deleted it for
30
- * an HTTP-only deploy should not fail this check.
36
+ * Every check skips cleanly when its input is absent — consumers who deleted
37
+ * `manifest.json` for an HTTP-only deploy, or who haven't built a bundle,
38
+ * should not fail the checks that need those files.
31
39
  *
32
40
  * @module scripts/lint-packaging
33
41
  */
34
- import { existsSync, readFileSync } from 'node:fs';
35
- import { resolve } from 'node:path';
42
+ import { execFileSync } from 'node:child_process';
43
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
44
+ import { join, resolve } from 'node:path';
45
+ import { fileURLToPath } from 'node:url';
36
46
 
37
47
  interface ServerJsonEnvVar {
38
48
  default?: string;
@@ -56,6 +66,7 @@ interface ManifestUserConfigEntry {
56
66
  }
57
67
 
58
68
  interface Manifest {
69
+ display_name?: unknown;
59
70
  name?: string;
60
71
  server?: { mcp_config?: { env?: Record<string, string> } };
61
72
  user_config?: Record<string, ManifestUserConfigEntry>;
@@ -69,20 +80,31 @@ const USER_CONFIG_REF = /^\$\{user_config\.([\w-]+)\}$/;
69
80
  * stripping nested runtime paths like `node_modules/x/skills/`. Keep in step
70
81
  * with the directory entries in `templates/_.mcpbignore`.
71
82
  */
72
- const KNOWN_DEV_DIRS = ['skills/', '.agents/', '.claude/'];
83
+ export const KNOWN_DEV_DIRS = ['skills/', '.agents/', '.claude/'];
73
84
 
74
85
  /**
75
86
  * Critical runtime paths that must NOT be stripped by any `.mcpbignore` pattern.
76
87
  * These are sampled representative paths — enough to catch a bare `skills/`
77
88
  * pattern accidentally stripping `node_modules/…/skills/`.
78
89
  */
79
- const CRITICAL_RUNTIME_PATHS = [
90
+ export const CRITICAL_RUNTIME_PATHS = [
80
91
  'node_modules/@opentelemetry/api/build/src/',
81
92
  'node_modules/@modelcontextprotocol/sdk/dist/',
82
93
  'node_modules/@cyanheads/mcp-ts-core/dist/',
83
94
  'dist/index.js',
84
95
  ];
85
96
 
97
+ /**
98
+ * Agent-doc entries under `node_modules/` that must not ship in a bundle.
99
+ * KEEP IN SYNC with `AGENT_DOC_ENTRY` in `scripts/clean-mcpb.ts` (the strip
100
+ * step this check verifies) — a unit test asserts the two are identical.
101
+ */
102
+ export const AGENT_DOC_ENTRY =
103
+ /^node_modules\/.*(?:\/skills\/|\/\.claude\/|\/\.agents\/|\/SKILL\.md$)/;
104
+
105
+ /** The canonical in-code identity pair — both must equal the unscoped package name. */
106
+ const IDENTITY_PAIR = ['name', 'title'] as const;
107
+
86
108
  function tryReadJson<T>(path: string): T | undefined {
87
109
  try {
88
110
  if (!existsSync(path)) return;
@@ -94,7 +116,7 @@ function tryReadJson<T>(path: string): T | undefined {
94
116
  }
95
117
 
96
118
  /**
97
- * Run bundle-content checks (5–7) against the .mcpbignore patterns.
119
+ * Run bundle-content checks (5–7) against raw `.mcpbignore` content.
98
120
  *
99
121
  * Uses the `ignore` package (already a devDependency in scaffolded servers)
100
122
  * to evaluate which paths survive the ignore rules. Returns an array of error
@@ -105,20 +127,32 @@ function tryReadJson<T>(path: string): T | undefined {
105
127
  * devDependencies (`^7.0.5`) and is therefore available in the server's
106
128
  * `node_modules` when `bun run lint:packaging` is invoked there.
107
129
  */
108
- async function checkBundleContent(mcpbignorePath: string): Promise<string[]> {
130
+ interface IgnoreMatcher {
131
+ add(patterns: string[]): IgnoreMatcher;
132
+ ignores(path: string): boolean;
133
+ }
134
+
135
+ /**
136
+ * The `ignore` package's factory, typed structurally — the CJS interop shape
137
+ * of `import('ignore')` differs between the script and test tsconfig programs,
138
+ * so naming the module's own types breaks one or the other.
139
+ */
140
+ type IgnoreFactory = (options?: unknown) => IgnoreMatcher;
141
+
142
+ export async function checkBundleContent(raw: string): Promise<string[]> {
109
143
  const errors: string[] = [];
110
144
 
111
- let createIgnore: typeof import('ignore')['default'];
145
+ let createIgnore: IgnoreFactory;
112
146
  try {
113
147
  // Dynamic import so the rest of the linter still runs when `ignore` is absent.
114
- createIgnore = ((await import('ignore')) as typeof import('ignore')).default;
148
+ const mod: unknown = await import('ignore');
149
+ createIgnore = ((mod as { default?: unknown }).default ?? mod) as IgnoreFactory;
115
150
  } catch {
116
151
  // `ignore` not installed — skip the guard without failing (e.g. in a minimal
117
152
  // CI environment that omits devDependencies).
118
153
  return errors;
119
154
  }
120
155
 
121
- const raw = readFileSync(mcpbignorePath, 'utf-8');
122
156
  const lines = raw
123
157
  .split('\n')
124
158
  .map((l) => l.trim())
@@ -180,81 +214,291 @@ async function checkBundleContent(mcpbignorePath: string): Promise<string[]> {
180
214
  return errors;
181
215
  }
182
216
 
183
- async function main(): Promise<void> {
184
- const manifestPath = resolve('manifest.json');
185
- if (!existsSync(manifestPath)) {
186
- console.log('No manifest.json skipping lint:packaging.');
187
- process.exit(0);
188
- }
217
+ /**
218
+ * Check 8: a built bundle must contain zero `node_modules/**` agent-doc
219
+ * entries. `scripts/clean-mcpb.ts` (wired into the `bundle` script) strips
220
+ * them after `mcpb pack`.
221
+ */
222
+ export function checkBundleEntries(entries: string[], bundleLabel: string): string[] {
223
+ const offending = entries.filter((entry) => AGENT_DOC_ENTRY.test(entry));
224
+ if (offending.length === 0) return [];
225
+ const sample = offending
226
+ .slice(0, 5)
227
+ .map((entry) => `\n ${entry}`)
228
+ .join('');
229
+ return [
230
+ `${bundleLabel} contains ${offending.length} node_modules agent-doc entries ` +
231
+ `(dependency-shipped skills/, .claude/, .agents/, SKILL.md) — re-run the \`bundle\` ` +
232
+ `script (scripts/clean-mcpb.ts strips them):${sample}`,
233
+ ];
234
+ }
235
+
236
+ /**
237
+ * Collect the direct (depth-1) property lines of the options object passed to
238
+ * `createApp()` / `createWorkerHandler()`. Returns undefined when no call with
239
+ * an inline options object exists (e.g. the framework's bare `createApp()`
240
+ * dev entry).
241
+ *
242
+ * Line-based, no AST: nested object literals (a `setup(core) { … }` body,
243
+ * `extensions: { … }`) are excluded by brace-depth tracking so an inner
244
+ * `name:`/`title:` key can't false-positive the identity check. Reliable for
245
+ * the template-scaffolded entrypoint shape with single-line string literals.
246
+ */
247
+ function identityCandidateLines(source: string): string[] | undefined {
248
+ const call = source.match(/\b(?:createApp|createWorkerHandler)\s*\(\s*\{/);
249
+ if (call?.index === undefined) return;
189
250
 
190
- const manifest = tryReadJson<Manifest>(manifestPath);
191
- if (!manifest) {
192
- console.error('manifest.json is unreadable or malformed.');
193
- process.exit(1);
251
+ const lines: string[] = [];
252
+ let depth = 0;
253
+ let lineStartDepth = 0;
254
+ let buf = '';
255
+ let inString: string | undefined;
256
+ let inBlockComment = false;
257
+
258
+ const flush = (): void => {
259
+ const trimmed = buf.trim();
260
+ if (
261
+ lineStartDepth === 1 &&
262
+ trimmed.length > 0 &&
263
+ !trimmed.startsWith('//') &&
264
+ !trimmed.startsWith('*')
265
+ ) {
266
+ lines.push(trimmed);
267
+ }
268
+ buf = '';
269
+ };
270
+
271
+ for (let i = call.index + call[0].length - 1; i < source.length; i++) {
272
+ const ch = source[i] as string;
273
+ if (ch === '\n') {
274
+ flush();
275
+ lineStartDepth = depth;
276
+ continue;
277
+ }
278
+ buf += ch;
279
+ if (inBlockComment) {
280
+ if (ch === '/' && source[i - 1] === '*') inBlockComment = false;
281
+ continue;
282
+ }
283
+ if (inString) {
284
+ if (ch === '\\') {
285
+ buf += source[i + 1] ?? '';
286
+ i++;
287
+ continue;
288
+ }
289
+ if (ch === inString) inString = undefined;
290
+ continue;
291
+ }
292
+ if (ch === '/' && source[i + 1] === '/') {
293
+ const nl = source.indexOf('\n', i);
294
+ const end = nl === -1 ? source.length : nl;
295
+ buf += source.slice(i + 1, end);
296
+ i = end - 1;
297
+ continue;
298
+ }
299
+ if (ch === '/' && source[i + 1] === '*') {
300
+ inBlockComment = true;
301
+ continue;
302
+ }
303
+ if (ch === "'" || ch === '"' || ch === '`') {
304
+ inString = ch;
305
+ continue;
306
+ }
307
+ if (ch === '{' || ch === '(' || ch === '[') {
308
+ depth++;
309
+ } else if (ch === '}' || ch === ')' || ch === ']') {
310
+ depth--;
311
+ if (depth === 0) {
312
+ flush();
313
+ return lines;
314
+ }
315
+ }
194
316
  }
317
+ flush();
318
+ return lines;
319
+ }
195
320
 
321
+ /**
322
+ * Check 9 (entrypoint surface): `name`/`title` literals passed to
323
+ * `createApp()` / `createWorkerHandler()` must equal the unscoped package
324
+ * name. A partial pair (one or both missing) warns without failing — explicit
325
+ * `name` also keeps scoped npm names out of the served `server_name`.
326
+ */
327
+ export function checkEntrypointIdentity(
328
+ source: string,
329
+ unscopedName: string,
330
+ fileLabel: string,
331
+ ): { errors: string[]; warnings: string[] } {
196
332
  const errors: string[] = [];
333
+ const warnings: string[] = [];
197
334
 
198
- if (manifest.name?.includes('/')) {
199
- errors.push(
200
- `manifest.json "name" contains a scope prefix ("${manifest.name}") — use the bare package name (e.g. "${manifest.name.split('/').pop()}")`,
201
- );
335
+ const optionLines = identityCandidateLines(source);
336
+ if (!optionLines) return { errors, warnings };
337
+
338
+ const present = new Set<string>();
339
+ for (const field of IDENTITY_PAIR) {
340
+ const fieldRe = new RegExp(`^['"\`]?${field}['"\`]?\\s*:`);
341
+ const literalRe = new RegExp(`^['"\`]?${field}['"\`]?\\s*:\\s*(['"\`])((?:(?!\\1).)*)\\1`);
342
+ for (const line of optionLines) {
343
+ if (!fieldRe.test(line)) continue;
344
+ present.add(field);
345
+ const literal = line.match(literalRe)?.[2];
346
+ if (literal !== undefined && literal !== unscopedName) {
347
+ errors.push(
348
+ `${fileLabel} sets ${field}: "${literal}" — must equal the unscoped package name ` +
349
+ `"${unscopedName}" (display identity is the machine name on every surface)`,
350
+ );
351
+ }
352
+ }
202
353
  }
203
354
 
204
- const userConfig = manifest.user_config ?? {};
205
- for (const [key, entry] of Object.entries(userConfig)) {
206
- if (typeof entry !== 'object' || entry === null) continue;
207
- const missing = (['title', 'type'] as const).filter(
208
- (f) => typeof entry[f] !== 'string' || (entry[f] as string).length === 0,
355
+ const missing = IDENTITY_PAIR.filter((field) => !present.has(field));
356
+ if (missing.length > 0) {
357
+ warnings.push(
358
+ `${fileLabel} identity pair is partial — missing: ${missing.join(', ')} ` +
359
+ `(set both name and title to the unscoped package name "${unscopedName}")`,
209
360
  );
210
- if (missing.length > 0) {
211
- errors.push(
212
- `manifest.json user_config["${key}"] is missing required field(s): ${missing.join(', ')} — mcpb pack will reject this`,
213
- );
214
- }
215
361
  }
216
362
 
217
- const serverJson = tryReadJson<ServerJson>(resolve('server.json'));
218
- if (serverJson) {
219
- const manifestEnv = manifest.server?.mcp_config?.env ?? {};
220
- const manifestEnvKeys = new Set(Object.keys(manifestEnv));
363
+ return { errors, warnings };
364
+ }
221
365
 
222
- const manifestUserConfigKeys = new Set(
223
- Object.entries(manifestEnv)
224
- .filter(([, v]) => typeof v === 'string' && USER_CONFIG_REF.test(v))
225
- .map(([k]) => k),
226
- );
366
+ /** Check 9 (manifest surface): `display_name`, when present, must be the unscoped package name. */
367
+ export function checkManifestIdentity(manifest: Manifest, unscopedName: string): string[] {
368
+ if (typeof manifest.display_name === 'string' && manifest.display_name !== unscopedName) {
369
+ return [
370
+ `manifest.json "display_name" is "${manifest.display_name}" — must equal the unscoped ` +
371
+ `package name "${unscopedName}"`,
372
+ ];
373
+ }
374
+ return [];
375
+ }
227
376
 
228
- const stdioEnvVars = (serverJson.packages ?? [])
229
- .filter((p) => p.transport?.type === 'stdio')
230
- .flatMap((p) => p.environmentVariables ?? []);
231
- const stdioEnvNames = new Set(stdioEnvVars.map((v) => v.name));
232
- const requiredStdioEnvNames = new Set(
233
- stdioEnvVars.filter((v) => v.isRequired === true && v.default == null).map((v) => v.name),
234
- );
377
+ async function main(): Promise<void> {
378
+ const errors: string[] = [];
379
+ const warnings: string[] = [];
380
+ const notes: string[] = [];
381
+
382
+ const pkg = tryReadJson<{ name?: string }>(resolve('package.json'));
383
+ const unscopedName = pkg?.name?.split('/').pop();
235
384
 
236
- const missingInServerJson = [...manifestUserConfigKeys].filter((k) => !stdioEnvNames.has(k));
237
- const missingInManifest = [...requiredStdioEnvNames].filter((k) => !manifestEnvKeys.has(k));
385
+ // ── Manifest-dependent checks (1–4 + manifest identity) ──
386
+ const manifestPath = resolve('manifest.json');
387
+ let manifest: Manifest | undefined;
388
+ if (existsSync(manifestPath)) {
389
+ manifest = tryReadJson<Manifest>(manifestPath);
390
+ if (!manifest) {
391
+ console.error('manifest.json is unreadable or malformed.');
392
+ process.exit(1);
393
+ }
238
394
 
239
- if (missingInServerJson.length > 0) {
395
+ if (manifest.name?.includes('/')) {
240
396
  errors.push(
241
- `manifest.json references user_config env var(s) not advertised in server.json stdio environmentVariables[]: ${missingInServerJson.join(', ')}`,
397
+ `manifest.json "name" contains a scope prefix ("${manifest.name}") use the bare package name (e.g. "${manifest.name.split('/').pop()}")`,
242
398
  );
243
399
  }
244
- if (missingInManifest.length > 0) {
245
- errors.push(
246
- `server.json declares required stdio env var(s) without default missing from manifest.json mcp_config.env: ${missingInManifest.join(', ')}`,
400
+
401
+ const userConfig = manifest.user_config ?? {};
402
+ for (const [key, entry] of Object.entries(userConfig)) {
403
+ if (typeof entry !== 'object' || entry === null) continue;
404
+ const missing = (['title', 'type'] as const).filter(
405
+ (f) => typeof entry[f] !== 'string' || (entry[f] as string).length === 0,
247
406
  );
407
+ if (missing.length > 0) {
408
+ errors.push(
409
+ `manifest.json user_config["${key}"] is missing required field(s): ${missing.join(', ')} — mcpb pack will reject this`,
410
+ );
411
+ }
248
412
  }
413
+
414
+ const serverJson = tryReadJson<ServerJson>(resolve('server.json'));
415
+ if (serverJson) {
416
+ const manifestEnv = manifest.server?.mcp_config?.env ?? {};
417
+ const manifestEnvKeys = new Set(Object.keys(manifestEnv));
418
+
419
+ const manifestUserConfigKeys = new Set(
420
+ Object.entries(manifestEnv)
421
+ .filter(([, v]) => typeof v === 'string' && USER_CONFIG_REF.test(v))
422
+ .map(([k]) => k),
423
+ );
424
+
425
+ const stdioEnvVars = (serverJson.packages ?? [])
426
+ .filter((p) => p.transport?.type === 'stdio')
427
+ .flatMap((p) => p.environmentVariables ?? []);
428
+ const stdioEnvNames = new Set(stdioEnvVars.map((v) => v.name));
429
+ const requiredStdioEnvNames = new Set(
430
+ stdioEnvVars.filter((v) => v.isRequired === true && v.default == null).map((v) => v.name),
431
+ );
432
+
433
+ const missingInServerJson = [...manifestUserConfigKeys].filter((k) => !stdioEnvNames.has(k));
434
+ const missingInManifest = [...requiredStdioEnvNames].filter((k) => !manifestEnvKeys.has(k));
435
+
436
+ if (missingInServerJson.length > 0) {
437
+ errors.push(
438
+ `manifest.json references user_config env var(s) not advertised in server.json stdio environmentVariables[]: ${missingInServerJson.join(', ')}`,
439
+ );
440
+ }
441
+ if (missingInManifest.length > 0) {
442
+ errors.push(
443
+ `server.json declares required stdio env var(s) without default missing from manifest.json mcp_config.env: ${missingInManifest.join(', ')}`,
444
+ );
445
+ }
446
+ }
447
+
448
+ if (unscopedName) {
449
+ errors.push(...checkManifestIdentity(manifest, unscopedName));
450
+ }
451
+ } else {
452
+ notes.push('No manifest.json — skipping manifest/server.json alignment checks.');
249
453
  }
250
454
 
251
- // Bundle-content guard (checks 5–7).
455
+ // ── Bundle-content guard (checks 5–7) ──
252
456
  const mcpbignorePath = resolve('.mcpbignore');
253
457
  if (existsSync(mcpbignorePath)) {
254
- const bundleErrors = await checkBundleContent(mcpbignorePath);
255
- errors.push(...bundleErrors);
458
+ errors.push(...(await checkBundleContent(readFileSync(mcpbignorePath, 'utf-8'))));
256
459
  }
257
460
 
461
+ // ── Post-bundle content check (8) ──
462
+ const distDir = resolve('dist');
463
+ if (existsSync(distDir)) {
464
+ for (const file of readdirSync(distDir).filter((f) => f.endsWith('.mcpb'))) {
465
+ try {
466
+ const listing = execFileSync('unzip', ['-Z1', join(distDir, file)], {
467
+ encoding: 'utf-8',
468
+ maxBuffer: 64 * 1024 * 1024,
469
+ });
470
+ errors.push(
471
+ ...checkBundleEntries(
472
+ listing.split('\n').filter((line) => line.length > 0),
473
+ `dist/${file}`,
474
+ ),
475
+ );
476
+ } catch (err) {
477
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
478
+ notes.push(`unzip not available — skipping bundle content check for dist/${file}.`);
479
+ } else {
480
+ errors.push(
481
+ `failed to list entries of dist/${file}: ${err instanceof Error ? err.message : err}`,
482
+ );
483
+ }
484
+ }
485
+ }
486
+ }
487
+
488
+ // ── Entrypoint identity check (9) ──
489
+ if (unscopedName) {
490
+ for (const entry of ['src/index.ts', 'src/worker.ts']) {
491
+ const entryPath = resolve(entry);
492
+ if (!existsSync(entryPath)) continue;
493
+ const result = checkEntrypointIdentity(readFileSync(entryPath, 'utf-8'), unscopedName, entry);
494
+ errors.push(...result.errors);
495
+ warnings.push(...result.warnings);
496
+ }
497
+ }
498
+
499
+ for (const note of notes) console.log(note);
500
+ for (const warning of warnings) console.warn(` ⚠ ${warning}`);
501
+
258
502
  if (errors.length === 0) {
259
503
  console.log('Packaging alignment OK.');
260
504
  process.exit(0);
@@ -263,4 +507,6 @@ async function main(): Promise<void> {
263
507
  process.exit(1);
264
508
  }
265
509
 
266
- await main();
510
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
511
+ await main();
512
+ }
@@ -4,7 +4,7 @@ description: >
4
4
  Testing patterns for MCP tool/resource handlers using `createMockContext` and Vitest. Covers mock context options, handler testing, McpError assertions, format testing, Vitest config setup, and test isolation conventions.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.4"
7
+ version: "1.5"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -19,6 +19,52 @@ Tests target handler behavior directly — call `handler(input, ctx)`, assert on
19
19
 
20
20
  ---
21
21
 
22
+ ## `mcpTest` — fixture-based Vitest test
23
+
24
+ `mcpTest` is a `test.extend`-based Vitest test that provides `ctx` and `storage` as **per-test fixtures** — fresh instances for every test, eliminating the `createMockContext()` boilerplate and enforcing the fresh-context-per-test convention automatically.
25
+
26
+ ```ts
27
+ import { mcpTest } from '@cyanheads/mcp-ts-core/testing/vitest';
28
+
29
+ mcpTest('echoes the message', async ({ ctx }) => {
30
+ const result = await echoTool.handler(echoTool.input.parse({ message: 'hi' }), ctx);
31
+ expect(result.message).toBe('hi');
32
+ });
33
+
34
+ mcpTest('uses storage fixture', async ({ ctx, storage }) => {
35
+ const svc = new MyService(config, storage);
36
+ const result = await svc.doWork(ctx);
37
+ expect(result).toBeDefined();
38
+ });
39
+ ```
40
+
41
+ ### Fixtures
42
+
43
+ | Fixture | Type | Per-test? | Notes |
44
+ |:--------|:-----|:----------|:------|
45
+ | `ctx` | `Context` | Yes | Fresh `createMockContext()` each test |
46
+ | `storage` | `StorageService` | Yes | Fresh `createInMemoryStorage()` each test |
47
+
48
+ ### Extending with the function form
49
+
50
+ Override fixtures using the **function form** (`async ({}, use) => { ... }`) to preserve per-test freshness. A bare-value override shares one mutable instance across the entire file — defeating the fixture's isolation guarantee.
51
+
52
+ ```ts
53
+ import { createMockContext } from '@cyanheads/mcp-ts-core/testing/vitest';
54
+
55
+ // Correct — function form gives each test a fresh context:
56
+ const tenantTest = mcpTest.extend({
57
+ ctx: async ({}, use) => { await use(createMockContext({ tenantId: 'test-tenant' })); },
58
+ });
59
+
60
+ // Wrong — bare value shares one ctx across every test in the file:
61
+ // const tenantTest = mcpTest.extend({ ctx: createMockContext({ tenantId: 'test-tenant' }) });
62
+ ```
63
+
64
+ `createMockContext` and `createInMemoryStorage` are re-exported from `@cyanheads/mcp-ts-core/testing/vitest` so overrides don't need a second import.
65
+
66
+ ---
67
+
22
68
  ## `createMockContext` options
23
69
 
24
70
  ```ts