@cyanheads/mcp-ts-core 0.12.6 → 0.12.7

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 (33) hide show
  1. package/AGENTS.md +2 -1
  2. package/CLAUDE.md +2 -1
  3. package/README.md +124 -76
  4. package/biome.json +1 -1
  5. package/changelog/0.12.x/0.12.6.md +2 -2
  6. package/changelog/0.12.x/0.12.7.md +39 -0
  7. package/dist/core/worker.d.ts +1 -1
  8. package/dist/core/worker.d.ts.map +1 -1
  9. package/dist/core/worker.js.map +1 -1
  10. package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
  11. package/dist/mcp-server/tools/tool-registration.js +7 -1
  12. package/dist/mcp-server/tools/tool-registration.js.map +1 -1
  13. package/dist/mcp-server/tools/utils/deferredInputSchema.d.ts +39 -0
  14. package/dist/mcp-server/tools/utils/deferredInputSchema.d.ts.map +1 -0
  15. package/dist/mcp-server/tools/utils/deferredInputSchema.js +33 -0
  16. package/dist/mcp-server/tools/utils/deferredInputSchema.js.map +1 -0
  17. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +10 -2
  18. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  19. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +25 -3
  20. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  21. package/dist/services/speech/providers/whisper.provider.d.ts.map +1 -1
  22. package/dist/services/speech/providers/whisper.provider.js +4 -2
  23. package/dist/services/speech/providers/whisper.provider.js.map +1 -1
  24. package/package.json +21 -20
  25. package/scripts/check-framework-antipatterns.ts +4 -1
  26. package/scripts/devcheck.ts +303 -33
  27. package/scripts/lint-packaging.ts +28 -6
  28. package/skills/add-tool/SKILL.md +33 -1
  29. package/skills/api-errors/SKILL.md +2 -1
  30. package/skills/design-mcp-server/SKILL.md +6 -1
  31. package/skills/field-test/SKILL.md +70 -30
  32. package/skills/git-wrapup/SKILL.md +4 -3
  33. package/templates/package.json +6 -6
@@ -139,6 +139,12 @@ interface Check {
139
139
  /** If true, this check is skipped in fast mode (typically network-bound or very slow). */
140
140
  slowCheck?: boolean;
141
141
  tip?: (c: Colors) => string;
142
+ /**
143
+ * Optional rewrite of the output shown in the summary, applied after
144
+ * `isSuccess` has read the tool's own output. Use when a tool reports a
145
+ * defensible verdict under a name the project does not recognize.
146
+ */
147
+ transformOutput?: (result: ShellResult) => string;
142
148
  }
143
149
 
144
150
  // =============================================================================
@@ -273,22 +279,275 @@ const OUTDATED_ALLOWLIST = new Set(DEVCHECK_CONFIG.outdated?.allowlist ?? []);
273
279
  /** Use bun for package management commands if available, otherwise npm. */
274
280
  const PM_CMD = spawnSync('bun', ['--version'], { stdio: 'ignore' }).status === 0 ? 'bun' : 'npm';
275
281
 
282
+ // ── Declared dependency identity (alias-aware) ───────────────────────
283
+
284
+ /** The package.json block that declared a dependency, as `bun outdated` marks it. */
285
+ type DependencyGroup = 'dev' | 'optional' | 'peer' | 'prod';
286
+
276
287
  /**
277
- * Direct dependencies from package.json, used to classify audit vulnerabilities
278
- * as direct (fixable by us) vs transitive/upstream (requires upstream fix).
288
+ * One `package.json` dependency entry resolved through any `npm:` alias.
289
+ * `bun outdated` reports rows under the *resolved* package name, so
290
+ * `"typescript-v6": "npm:typescript@^6.0.3"` is printed as `typescript`.
291
+ * Carrying the declared key alongside the resolved target is what lets the
292
+ * Outdated check name — and allowlist — the dependency the project declares.
279
293
  */
280
- const DIRECT_DEPS: ReadonlySet<string> = (() => {
294
+ interface DeclaredDependency {
295
+ group: DependencyGroup;
296
+ /** The `package.json` key. */
297
+ key: string;
298
+ /** Version range with any `npm:<target>@` alias prefix stripped. */
299
+ range: string;
300
+ /** Registry package name the key resolves to; equals `key` when not aliased. */
301
+ target: string;
302
+ }
303
+
304
+ const DEPENDENCY_GROUPS: ReadonlyArray<readonly [DependencyGroup, string]> = [
305
+ ['prod', 'dependencies'],
306
+ ['dev', 'devDependencies'],
307
+ ['peer', 'peerDependencies'],
308
+ ['optional', 'optionalDependencies'],
309
+ ];
310
+
311
+ /** Splits an `npm:<target>@<range>` alias spec; returns null for a plain range. */
312
+ function parseAliasSpec(spec: string): { range: string; target: string } | null {
313
+ if (!spec.startsWith('npm:')) return null;
314
+ const rest = spec.slice(4);
315
+ // Scoped targets lead with `@`, so only a later `@` separates the range.
316
+ const separator = rest.lastIndexOf('@');
317
+ if (separator <= 0) return { range: '*', target: rest };
318
+ return { range: rest.slice(separator + 1), target: rest.slice(0, separator) };
319
+ }
320
+
321
+ const DECLARED_DEPENDENCIES: readonly DeclaredDependency[] = (() => {
281
322
  try {
282
323
  const pkg = JSON.parse(readFileSync(path.join(ROOT_DIR, 'package.json'), 'utf-8'));
283
- return new Set<string>([
284
- ...Object.keys(pkg.dependencies ?? {}),
285
- ...Object.keys(pkg.devDependencies ?? {}),
286
- ]);
324
+ const declared: DeclaredDependency[] = [];
325
+ for (const [group, block] of DEPENDENCY_GROUPS) {
326
+ for (const [key, spec] of Object.entries(pkg[block] ?? {})) {
327
+ if (typeof spec !== 'string') continue;
328
+ const alias = parseAliasSpec(spec);
329
+ declared.push({ group, key, range: alias?.range ?? spec, target: alias?.target ?? key });
330
+ }
331
+ }
332
+ return declared;
287
333
  } catch {
288
- return new Set<string>();
334
+ return [];
289
335
  }
290
336
  })();
291
337
 
338
+ /** Direct dependency keys used to distinguish direct audit findings from transitive ones. */
339
+ const DIRECT_DEPS: ReadonlySet<string> = new Set(
340
+ DECLARED_DEPENDENCIES.filter(({ group }) => group === 'prod' || group === 'dev').map(
341
+ ({ key }) => key,
342
+ ),
343
+ );
344
+
345
+ // ── Minimal range matching (alias attribution only) ──────────────────
346
+
347
+ type VersionTriple = readonly [number, number, number];
348
+
349
+ /** Parses a possibly partial version (`6`, `0.12`, `1.2.3-rc.1`) plus its precision. */
350
+ function parseVersionParts(value: string): { segments: number; triple: VersionTriple } | null {
351
+ const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(value.trim().replace(/^[v=\s]+/, ''));
352
+ if (!match) return null;
353
+ const segments = match[3] !== undefined ? 3 : match[2] !== undefined ? 2 : 1;
354
+ return {
355
+ segments,
356
+ triple: [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)],
357
+ };
358
+ }
359
+
360
+ function compareVersions(a: VersionTriple, b: VersionTriple): number {
361
+ for (let index = 0; index < 3; index++) {
362
+ const difference = (a[index] ?? 0) - (b[index] ?? 0);
363
+ if (difference !== 0) return difference < 0 ? -1 : 1;
364
+ }
365
+ return 0;
366
+ }
367
+
368
+ /** The exclusive upper bound of a caret range, honoring 0.x and partial versions. */
369
+ function caretCeiling(bound: VersionTriple, segments: number): VersionTriple {
370
+ if (bound[0] > 0) return [bound[0] + 1, 0, 0];
371
+ if (segments === 1) return [1, 0, 0];
372
+ if (bound[1] > 0) return [0, bound[1] + 1, 0];
373
+ if (segments === 2) return [0, 1, 0];
374
+ return [0, 0, bound[2] + 1];
375
+ }
376
+
377
+ /**
378
+ * Whether a version satisfies one comparator. Covers the operators a
379
+ * hand-written `package.json` range uses (`^`, `~`, exact, the four
380
+ * inequalities). Anything unrecognized is treated as unconstrained, so an
381
+ * exotic range never *excludes* a candidate — attribution then reports
382
+ * ambiguity instead of guessing.
383
+ */
384
+ function comparatorAdmits(comparator: string, version: VersionTriple): boolean {
385
+ const token = comparator.trim();
386
+ if (token === '' || token === '*' || token === 'x' || token === 'latest') return true;
387
+ const match = /^(\^|~|>=|<=|>|<|=)?\s*(.+)$/.exec(token);
388
+ const parsed = parseVersionParts(match?.[2] ?? '');
389
+ if (!parsed) return true;
390
+ const order = compareVersions(version, parsed.triple);
391
+ switch (match?.[1] ?? '=') {
392
+ case '>':
393
+ return order > 0;
394
+ case '>=':
395
+ return order >= 0;
396
+ case '<':
397
+ return order < 0;
398
+ case '<=':
399
+ return order <= 0;
400
+ case '~': {
401
+ const ceiling: VersionTriple =
402
+ parsed.segments === 1
403
+ ? [parsed.triple[0] + 1, 0, 0]
404
+ : [parsed.triple[0], parsed.triple[1] + 1, 0];
405
+ return order >= 0 && compareVersions(version, ceiling) < 0;
406
+ }
407
+ case '^':
408
+ return (
409
+ order >= 0 && compareVersions(version, caretCeiling(parsed.triple, parsed.segments)) < 0
410
+ );
411
+ default:
412
+ return order === 0;
413
+ }
414
+ }
415
+
416
+ /** Whether an installed version falls inside a declared range. */
417
+ function rangeAdmits(range: string, version: string): boolean {
418
+ const parsed = parseVersionParts(version);
419
+ if (!parsed) return true;
420
+ return range.split('||').some((alternative) =>
421
+ alternative
422
+ .trim()
423
+ .split(/\s+/)
424
+ .every((comparator) => comparatorAdmits(comparator, parsed.triple)),
425
+ );
426
+ }
427
+
428
+ // ── `bun outdated` row attribution ───────────────────────────────────
429
+
430
+ /** Trailing workspace-type marker `bun outdated` appends to the package cell. */
431
+ const OUTDATED_GROUP_MARKER = /\s*\((dev|peer|prod|optional)\)$/;
432
+
433
+ /** One parsed `bun outdated` table row, resolved back to what package.json declares. */
434
+ interface OutdatedRow {
435
+ /** Declared keys the row could belong to, in declaration order. */
436
+ candidates: string[];
437
+ current: string;
438
+ /** The declared key when attribution is unambiguous, else null. */
439
+ declaredKey: string | null;
440
+ group: DependencyGroup | null;
441
+ /** The row verbatim, used to key the rendered rewrite back to its line. */
442
+ line: string;
443
+ /** First cell as printed, marker included. */
444
+ rawName: string;
445
+ /** Resolved package name `bun outdated` printed. */
446
+ target: string;
447
+ update: string;
448
+ }
449
+
450
+ /**
451
+ * Maps a printed row back to the `package.json` keys that could have produced
452
+ * it. A target-name-only map is not enough: a direct dependency and one or more
453
+ * aliases can share a target, so candidates are narrowed by which declared
454
+ * range admits the installed version. When that still leaves more than one, the
455
+ * row is reported as ambiguous rather than attributed — an alias's allowlist
456
+ * entry must never stand in for a same-named direct dependency's finding.
457
+ */
458
+ function attributeOutdatedRow(
459
+ target: string,
460
+ group: DependencyGroup | null,
461
+ current: string,
462
+ ): Pick<OutdatedRow, 'candidates' | 'declaredKey'> {
463
+ const matches = DECLARED_DEPENDENCIES.filter(
464
+ (declared) => declared.target === target && (group === null || declared.group === group),
465
+ );
466
+ const candidates = matches.map((declared) => declared.key);
467
+ if (candidates.length <= 1) return { candidates, declaredKey: candidates[0] ?? null };
468
+
469
+ const admitting = matches.filter((declared) => rangeAdmits(declared.range, current));
470
+ return { candidates, declaredKey: admitting.length === 1 ? (admitting[0]?.key ?? null) : null };
471
+ }
472
+
473
+ /** Parses the package rows out of a `bun outdated` table, skipping its chrome. */
474
+ function parseOutdatedRows(output: string): OutdatedRow[] {
475
+ const rows: OutdatedRow[] = [];
476
+ // `bun outdated` emits markdown-style rows (`| col1 | col2 | ... |`), so
477
+ // split('|') yields an empty leading cell — package data starts at index [1].
478
+ for (const line of output.split('\n')) {
479
+ if (!line.includes('|')) continue;
480
+ const cells = line.split('|').map((cell) => cell.trim());
481
+ const rawName = cells[1] ?? '';
482
+ // Skip table chrome: header row and separator (e.g., "---")
483
+ if (!rawName || rawName === 'Package' || /^-+$/.test(rawName)) continue;
484
+ const group = (OUTDATED_GROUP_MARKER.exec(rawName)?.[1] ?? null) as DependencyGroup | null;
485
+ const target = rawName.replace(OUTDATED_GROUP_MARKER, '');
486
+ const current = cells[2] ?? '';
487
+ const update = (cells[3] ?? '').replace(/\*/g, '').trim();
488
+ rows.push({
489
+ ...attributeOutdatedRow(target, group, current),
490
+ current,
491
+ group,
492
+ line,
493
+ rawName,
494
+ target,
495
+ update,
496
+ });
497
+ }
498
+ return rows;
499
+ }
500
+
501
+ /**
502
+ * Reprints the table with each row named by the dependency the project declares,
503
+ * so an alias row reads as `typescript-v6 (dev)` rather than `typescript`. Rows
504
+ * whose attribution is ambiguous keep the printed name and are called out below
505
+ * the table.
506
+ */
507
+ function renderOutdatedTable(output: string): string {
508
+ if (!output.includes('|')) return output;
509
+ const attributed = new Map(parseOutdatedRows(output).map((row) => [row.line, row]));
510
+
511
+ const lines = output.split('\n').map((line) => {
512
+ const cell = line.split('|')[1];
513
+ if (cell === undefined || /^-+$/.test(cell.trim())) return { line, display: null };
514
+ const row = attributed.get(line);
515
+ let display = cell.trim();
516
+ if (row?.declaredKey && row.declaredKey !== row.target) {
517
+ display = row.group ? `${row.declaredKey} (${row.group})` : row.declaredKey;
518
+ }
519
+ return { line, display };
520
+ });
521
+
522
+ // Keep bun's own column width unless a declared key needs more room; the
523
+ // horizontal rules then grow by the same delta so the table stays square.
524
+ const headerCell = lines.find(({ display }) => display === 'Package')?.line.split('|')[1];
525
+ const baseWidth = Math.max((headerCell?.length ?? 2) - 2, 0);
526
+ const width = Math.max(baseWidth, ...lines.map(({ display }) => display?.length ?? 0));
527
+ const delta = width - baseWidth;
528
+ const rendered = lines.map(({ line, display }) => {
529
+ if (display === null) {
530
+ return delta > 0 && line.includes('|')
531
+ ? line.replace(/-+/, (run) => run.padEnd(run.length + delta, '-'))
532
+ : line;
533
+ }
534
+ return `| ${display.padEnd(width)} ${line.slice(line.indexOf('|', line.indexOf('|') + 1))}`;
535
+ });
536
+
537
+ const ambiguous = [...attributed.values()].filter(
538
+ (row) => row.candidates.length > 1 && row.declaredKey === null,
539
+ );
540
+ if (ambiguous.length === 0) return rendered.join('\n');
541
+ return [
542
+ ...rendered,
543
+ '',
544
+ ...ambiguous.map(
545
+ (row) =>
546
+ `! ${row.rawName} matches ${row.candidates.join(' and ')} — attribution is ambiguous, so no allowlist entry applies.`,
547
+ ),
548
+ ].join('\n');
549
+ }
550
+
292
551
  /**
293
552
  * Parses `bun audit` output and classifies high/critical vulnerabilities as
294
553
  * direct (in our package.json) or upstream (transitive dependency we can't fix).
@@ -589,6 +848,26 @@ const ALL_CHECKS: Check[] = [
589
848
  getCommand: (ctx) => [path.join(ctx.rootDir, 'node_modules', '.bin', 'tsc'), '--noEmit'],
590
849
  tip: () => 'Check TypeScript errors in your IDE or the console output.',
591
850
  },
851
+ {
852
+ name: 'TypeScript (Worker)',
853
+ flag: '--no-types-worker',
854
+ canFix: false,
855
+ // The workerd type environment is its own program: Cloudflare's ambient
856
+ // globals cannot share one with @types/node's (#397). It reads the built
857
+ // declarations, so it only has something to check after a build.
858
+ getCommand: (ctx) => {
859
+ if (!existsSync(path.join(ctx.rootDir, 'tsconfig.worker.json'))) return null;
860
+ if (!existsSync(path.join(ctx.rootDir, 'dist'))) return null;
861
+ return [
862
+ path.join(ctx.rootDir, 'node_modules', '.bin', 'tsc'),
863
+ '--project',
864
+ 'tsconfig.worker.json',
865
+ '--noEmit',
866
+ ];
867
+ },
868
+ tip: (c) =>
869
+ `Build first (${c.bold('bun run build')}), then ${c.bold('bun run typecheck:worker')}.`,
870
+ },
592
871
  {
593
872
  name: 'Tests',
594
873
  flag: '--test',
@@ -681,34 +960,21 @@ const ALL_CHECKS: Check[] = [
681
960
  const output = result.stdout.trim();
682
961
  if (result.exitCode !== 0 && !output.includes('|')) return false;
683
962
 
684
- // Parse the tabular output. `bun outdated` emits markdown-style rows
685
- // (`| col1 | col2 | ... |`), so split('|') yields an empty leading cell —
686
- // package data starts at index [1]. Strip the trailing `(dev|peer|prod|optional)`
687
- // workspace-type marker so the allowlist takes the bare package name.
688
- const lines = output.split('\n');
689
- const stripWorkspaceMarker = (cell: string): string =>
690
- cell.replace(/\s*\((?:dev|peer|prod|optional)\)$/, '');
691
- const packageLines = lines.filter((line) => {
692
- if (!line.includes('|')) return false;
693
- // Skip table chrome: header row and separator (e.g., "---")
694
- const firstCell = line.split('|')[1]?.trim() ?? '';
695
- if (!firstCell || firstCell === 'Package' || /^-+$/.test(firstCell)) return false;
696
- return true;
697
- });
698
-
699
963
  // A row is a real finding only if it's neither allowlisted, a peer range,
700
964
  // nor a version held back by bunfig's `minimumReleaseAge` supply-chain guard.
701
- const unexpected = packageLines.filter((line) => {
702
- const cells = line.split('|').map((cell) => cell.trim());
703
- const rawName = cells[1] ?? '';
704
- const pkgName = stripWorkspaceMarker(rawName);
705
- if (OUTDATED_ALLOWLIST.has(pkgName)) return false;
965
+ const unexpected = parseOutdatedRows(output).filter((row) => {
966
+ // The allowlist is keyed on the `package.json` key, so an aliased row
967
+ // matches through its resolved declaration. Where attribution stayed
968
+ // ambiguous, no entry applies — suppressing the row would risk hiding a
969
+ // same-named direct dependency behind an alias's exemption.
970
+ const ambiguous = row.candidates.length > 1 && row.declaredKey === null;
971
+ if (!ambiguous && OUTDATED_ALLOWLIST.has(row.declaredKey ?? row.target)) return false;
706
972
 
707
973
  // A peerDependency range declares the *lowest* version supported, not
708
974
  // the version to track — widening it as upstream publishes only narrows
709
975
  // what consumers may install. Currency for the versions actually
710
976
  // exercised is enforced through the matching devDependency row.
711
- if (rawName.endsWith('(peer)')) return false;
977
+ if (row.group === 'peer') return false;
712
978
 
713
979
  // `Update` is the newest version installable under the declared range;
714
980
  // `Latest` ignores the range. Update === Current means there is nothing
@@ -717,13 +983,12 @@ const ALL_CHECKS: Check[] = [
717
983
  // 13.0.1). Crossing that cap is a deliberate range change, i.e.
718
984
  // maintenance work rather than a gate failure. The gate fails on what
719
985
  // `bun update` would actually change: being behind within the range.
720
- const current = cells[2] ?? '';
721
- const update = (cells[3] ?? '').replace(/\*/g, '').trim();
722
- return !(current !== '' && update === current);
986
+ return !(row.current !== '' && row.update === row.current);
723
987
  });
724
988
 
725
989
  return unexpected.length === 0;
726
990
  },
991
+ transformOutput: (result) => renderOutdatedTable(result.stdout),
727
992
  tip: (c) =>
728
993
  `Run ${c.bold(`${PM_CMD} update`)} to upgrade; the ${c.bold('maintenance')} skill then investigates changelogs and adopts upstream changes. Configure allowlist in ${c.bold('devcheck.config.json')}.`,
729
994
  },
@@ -967,7 +1232,7 @@ function parseArgs(args: string[]): Omit<AppContext, 'rootDir' | 'stagedFiles'>
967
1232
  }
968
1233
 
969
1234
  async function runCheck(check: Check, ctx: AppContext): Promise<CommandResult> {
970
- const { name, getCommand, isSuccess } = check;
1235
+ const { name, getCommand, isSuccess, transformOutput } = check;
971
1236
  const log: string[] = [];
972
1237
  const baseResult: CommandResult = {
973
1238
  checkName: name,
@@ -1070,6 +1335,11 @@ async function runCheck(check: Check, ctx: AppContext): Promise<CommandResult> {
1070
1335
  }
1071
1336
  }
1072
1337
 
1338
+ // 8. Rewrite the displayed output — after isSuccess has read the tool's own.
1339
+ if (transformOutput) {
1340
+ finalResult.stdout = transformOutput(finalResult);
1341
+ }
1342
+
1073
1343
  log.push(UI.formatCheckResult(finalResult, uiMode));
1074
1344
 
1075
1345
  return finalResult;
@@ -38,7 +38,10 @@
38
38
  * (`name`, server key, `interface.displayName`) carry the unscoped machine
39
39
  * name while the `npx -y` install arg carries the full `package.json`
40
40
  * name (scoped if scoped). An unscoped install arg for a scoped package
41
- * is a guaranteed install 404. Gated by `devcheck.config.json`
41
+ * is a guaranteed install 404. Each present plugin manifest's `version`
42
+ * must equal `package.json`'s, so a release cannot ship stale plugin
43
+ * metadata (issue #393); `.codex-plugin/mcp.json` is connection config and
44
+ * carries no version. Gated by `devcheck.config.json`
42
45
  * `packaging.pluginManifests` (default on); each manifest is skipped
43
46
  * cleanly when absent (issue #240).
44
47
  *
@@ -424,20 +427,36 @@ function installArg(entry: Record<string, unknown>): unknown {
424
427
  /**
425
428
  * Check 10: plugin marketplace manifests. Display fields (`name`, server key,
426
429
  * `interface.displayName`) must equal the unscoped machine name; the install
427
- * arg must equal the full `package.json` name (the real `npx` target). Empty
428
- * descriptions ship blank marketplace cards. Each manifest is validated only
429
- * when present, so HTTP-only and non-plugin consumers are unaffected. The
430
- * caller gates the whole check on `packaging.pluginManifests`.
430
+ * arg must equal the full `package.json` name (the real `npx` target); the
431
+ * declared `version` must equal `package.json`'s, so a release cannot ship a
432
+ * manifest advertising an earlier one. Empty descriptions ship blank
433
+ * marketplace cards. Each manifest is validated only when present, so HTTP-only
434
+ * and non-plugin consumers are unaffected. The caller gates the whole check on
435
+ * `packaging.pluginManifests`.
436
+ *
437
+ * `.codex-plugin/mcp.json` is connection configuration and carries no version.
431
438
  */
432
439
  export function checkPluginManifests(
433
440
  inputs: PluginManifestInputs,
434
441
  unscopedName: string,
435
442
  fullName: string,
443
+ packageVersion?: string,
436
444
  ): string[] {
437
445
  const errors: string[] = [];
438
446
  const optOut =
439
447
  '(or set "packaging": { "pluginManifests": false } in devcheck.config.json to opt out)';
440
448
 
449
+ /** Version parity for one plugin manifest; skipped when package.json has none. */
450
+ const checkVersion = (file: string, manifest: Record<string, unknown>): void => {
451
+ if (!isNonEmptyString(packageVersion)) return;
452
+ if (manifest.version === packageVersion) return;
453
+ errors.push(
454
+ manifest.version === undefined
455
+ ? `${file} has no "version" — must declare the package.json version "${packageVersion}"`
456
+ : `${file} "version" is "${String(manifest.version)}" — must equal the package.json version "${packageVersion}"`,
457
+ );
458
+ };
459
+
441
460
  // ── .claude-plugin/plugin.json ──
442
461
  const claude = inputs.claudePlugin;
443
462
  if (isRecord(claude)) {
@@ -445,6 +464,7 @@ export function checkPluginManifests(
445
464
  if (!isNonEmptyString(claude.description)) {
446
465
  errors.push(`${f} "description" is empty — populate it ${optOut}`);
447
466
  }
467
+ checkVersion(f, claude);
448
468
  if (claude.name !== unscopedName) {
449
469
  errors.push(
450
470
  `${f} "name" is "${String(claude.name)}" — must equal the unscoped package name "${unscopedName}"`,
@@ -474,6 +494,7 @@ export function checkPluginManifests(
474
494
  if (!isNonEmptyString(codex.description)) {
475
495
  errors.push(`${f} "description" is empty — populate it ${optOut}`);
476
496
  }
497
+ checkVersion(f, codex);
477
498
  if (codex.name !== unscopedName) {
478
499
  errors.push(
479
500
  `${f} "name" is "${String(codex.name)}" — must equal the unscoped package name "${unscopedName}"`,
@@ -533,7 +554,7 @@ async function main(): Promise<void> {
533
554
  const warnings: string[] = [];
534
555
  const notes: string[] = [];
535
556
 
536
- const pkg = tryReadJson<{ name?: string }>(resolve('package.json'));
557
+ const pkg = tryReadJson<{ name?: string; version?: string }>(resolve('package.json'));
537
558
  const unscopedName = pkg?.name?.split('/').pop();
538
559
 
539
560
  // ── Manifest-dependent checks (1–4 + manifest identity) ──
@@ -662,6 +683,7 @@ async function main(): Promise<void> {
662
683
  },
663
684
  unscopedName,
664
685
  pkg.name,
686
+ pkg.version,
665
687
  ),
666
688
  );
667
689
  } else {
@@ -4,7 +4,7 @@ description: >
4
4
  Scaffold a new MCP tool definition. Use when the user asks to add a tool, create a new tool, or implement a new capability for the server.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.21"
7
+ version: "2.22"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -226,6 +226,37 @@ export const submitObservations = getServerConfig().enableWrites
226
226
 
227
227
  The wrapper preserves all original definition fields (handler, schemas, auth scopes, error contracts) — when re-enabled, the tool already conforms to every lint rule.
228
228
 
229
+ #### Audit what still names the tool
230
+
231
+ Gating a tool removes it from `tools/list`, but nothing rewrites the rest of the server. Every reference that survives points a client at a name it cannot call. Sweep for the tool's name across three surfaces and fix what the gate makes wrong:
232
+
233
+ | Surface | What the gate requires |
234
+ |:---|:---|
235
+ | **Static prose** — server `instructions`, tool descriptions, field `.describe()` text | Do not describe a disabled tool as currently callable. |
236
+ | **Recovery text** — `errors[].recovery`, `ctx.fail` hints, `ctx.enrich` notices, service summaries | Offer an available next step, or say the capability is unavailable in this deployment. |
237
+ | **Structured suggestions** — `nextToolSuggestions`, or any `{ toolName, args }` entry a client executes | Emit a suggestion only when its target is enabled under the same configuration. |
238
+
239
+ A suggestion is executable; prose is not. When no callable alternative exists, prose may still explain the limitation — but the executable entry goes:
240
+
241
+ ```typescript
242
+ const { enableWrites } = getServerConfig();
243
+
244
+ // The suggestion is emitted only under the config that registers its target.
245
+ const nextToolSuggestions = enableWrites
246
+ ? [{ toolName: 'brapi_submit_observations', args: { studyDbId } }]
247
+ : [];
248
+
249
+ return {
250
+ observations,
251
+ nextToolSuggestions,
252
+ ...(enableWrites
253
+ ? {}
254
+ : { notice: 'Submitting observations is turned off in this deployment.' }),
255
+ };
256
+ ```
257
+
258
+ The same audit applies to a tool's own `errors[].recovery`: a hint naming a tool that this deployment gates off sends the agent to a dead end at exactly the moment it is recovering from a failure.
259
+
229
260
  ## Schemas: what the framework stores vs. what clients see
230
261
 
231
262
  `tool()` and the handler factory do not hand your Zod schemas to the SDK verbatim. Two deliberate transforms sit in between.
@@ -796,6 +827,7 @@ return { items: hits };
796
827
  - [ ] If tool returns unbounded arrays: pagination with total count, or `spillover()` / DataCanvas for *analytical* working sets (an agent would SQL them — not a discovery/search surface). If any tool emits a `canvas_id`, a `dataframe_query` tool is registered in the same server — a token with no query tool is dead output
797
828
  - [ ] If tool returns one large *document* (not a row set) that can overflow context: `outlineOnOverflow()` returns a `full | outline` union so the agent re-calls with `sections: [...]` — not one-sided truncation
798
829
  - [ ] If tool is feature-gated: evaluated whether `disabledTool()` wrapper is appropriate (present in manifest but uncallable)
830
+ - [ ] If a tool is gated off: swept the server for its name — no prose calls it available, no recovery hint routes to it, and every structured suggestion naming it is emitted only under the config that registers it
799
831
  - [ ] If the tool filters a bounded list locally (no upstream search): a distinct local param (`filter`/`nameContains`, not `query`), filters the full set (not one page), strict token match by default
800
832
  - [ ] Registered in the project's existing `createApp()` tool list (directly or via barrel)
801
833
  - [ ] Test file created via `add-test` skill, or handler tested directly with `createMockContext()`
@@ -4,7 +4,7 @@ description: >
4
4
  McpError constructor, JsonRpcErrorCode reference, and error handling patterns for `@cyanheads/mcp-ts-core`. Use when looking up error codes, understanding where errors should be thrown vs. caught, or using ErrorHandler.tryCatch in services.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.8"
7
+ version: "1.9"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -362,6 +362,7 @@ Important properties:
362
362
  - **`_meta.error` is NOT emitted.** Error code/data live on `structuredContent.error` instead. Don't read `_meta.error` in clients or tests — it doesn't exist.
363
363
  - **`data` propagation is restricted** to explicitly-thrown `McpError.data` and `ZodError.issues`. Auto-classified plain errors (`TypeError`, network errors, etc.) emit `code` + `message` only — no `data` — so internal classification context never leaks to clients.
364
364
  - **Recovery hint mirroring is automatic.** When the thrown `McpError` carries `data.recovery.hint`, the handler factory appends it to the `content[]` text so the markdown surface matches the JSON surface. Authors don't need to format the hint manually.
365
+ - **Argument-schema rejection is a tool error with the same envelope.** An unknown root key, a wrong type, a missing required field, or a failed constraint returns `isError: true` with `structuredContent.error.code = -32602` (`InvalidParams`) and the readable `Invalid arguments for tool <name>: …` diagnostic in `content[]`. The handler never runs. Two neighbouring failures keep the protocol error path instead, arriving as a JSON-RPC error rather than a tool result: an unknown or disabled tool name, and a malformed request envelope.
365
366
 
366
367
  **Handler — throw freely, no try/catch:**
367
368
 
@@ -4,7 +4,7 @@ description: >
4
4
  Design the tool surface, resources, and service layer for a new MCP server. Use when starting a new server, planning a major feature expansion, or when the user describes a domain/API they want to expose via MCP. Produces a design doc at docs/design.md that drives implementation.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.23"
7
+ version: "2.24"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -257,6 +257,8 @@ const wrapupInstructions = tool('git_wrapup_instructions', {
257
257
 
258
258
  Prior art: [`git_wrapup_instructions`](https://github.com/cyanheads/git-mcp-server) walks through staging, commit, and push with repo state inspected. If a server has recurring "how do I do X well given my state" questions, an instruction tool typically beats N topic-specific tools and duplicating guidance in tool descriptions.
259
259
 
260
+ **Suggestions are scoped to what this deployment registers.** A `nextToolSuggestions` entry is an executable call, so it is only correct when its target is enabled under the same configuration — a tool wrapped in `disabledTool()` is absent from `tools/list`, and a suggestion naming it hands the agent a call that fails on dispatch. Build the array from the same config the registration reads, and when the target is off, drop the entry rather than the explanation: `guidance` can still say the capability is unavailable in this deployment and what to do instead. The audit and a worked example live under *Feature-flagged tools* in `add-tool/SKILL.md`.
261
+
260
262
  #### Reference tools
261
263
 
262
264
  **Applies when:** the domain speaks in opaque vocabulary — enum codes, classification systems, identifier formats, per-source coverage windows — that agents must supply as inputs elsewhere. Skip when inputs are self-evident (free text, ISO dates, well-known formats).
@@ -493,6 +495,8 @@ throw notFound(`Paper '${id}' not found on arXiv. Verify the ID format (e.g., '2
493
495
 
494
496
  **During design, settle the full contract for each tool** — reason, code, when-clause, *and the verbatim `recovery` string* — in the tool's section of the design doc; they become the literal `errors: [...]` entries during scaffolding. Hold every recovery string (and zero-hit notice, and resolver `guidance`) to the **no-dead-ends rule: it names the concrete next tool call**, with the reference tool as the most common routing target. Settled at design time these stay sharp; left to implementation they degrade into "check your input." Not every failure needs a contract entry; baseline infrastructure errors (5xx, timeouts, validation) are fine to let bubble.
495
497
 
498
+ **A routing target must be callable in the deployment doing the routing.** A recovery string, notice, or `guidance` line that names a config-gated tool is a dead end wherever that gate is off — the agent is sent to a tool absent from `tools/list`, at the moment it is already recovering from a failure. Prefer routing to ungated tools (the reference tool is a good target precisely because nothing gates it). Where the target genuinely is gated, resolve the text from the same config that decides registration, and say the capability is unavailable in this deployment rather than naming a call that cannot be made. Structured follow-ups are stricter still — see *Instruction tools* above.
499
+
496
500
  #### Design table
497
501
 
498
502
  Summarize each tool:
@@ -701,6 +705,7 @@ Items without an `If …:` prefix apply to every design. Conditional items only
701
705
  - [ ] **If an upstream API has no native search but the relevant set is bounded:** MCP-side list filtering considered — a distinct local filter param (`filter`/`nameContains`, not `query`), filtering the full set, strict token match (fuzzy only when a caller needs typo tolerance)
702
706
  - [ ] **If the server has workflow tools:** call-flow documented (upstream sequence + mode arms) in design doc's Workflow Analysis
703
707
  - [ ] **If state-aware procedural guidance adds value:** instruction tool considered with `nextToolSuggestions` pre-filled from diagnostics
708
+ - [ ] **If any tool is config-gated:** nothing routes to it while the gate is off — recovery strings, notices, and `guidance` name a callable target or state the capability is unavailable, and structured follow-ups naming it are emitted only under the config that registers it
704
709
  - [ ] **If workflow tools have destructive modes:** destructive arm gated on a `ctx.requestInput` confirmation read back from `ctx.inputs`, with `destructiveHint` annotation so clients that never fulfil the round still surface the risk
705
710
  - [ ] **If a parameter determines blast radius:** safe default set (e.g., `mode: 'preview'`, `dryRun: true`, `confirmCount` required)
706
711
  - [ ] **App tools default to no.** If one was proposed, verified there's a real human-in-the-loop in an MCP Apps-capable client justifying the iframe/CSP/`format()`-twin maintenance cost — otherwise dropped in favor of a standard tool