@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.
- package/AGENTS.md +2 -1
- package/CLAUDE.md +2 -1
- package/README.md +124 -76
- package/biome.json +1 -1
- package/changelog/0.12.x/0.12.6.md +2 -2
- package/changelog/0.12.x/0.12.7.md +39 -0
- package/dist/core/worker.d.ts +1 -1
- package/dist/core/worker.d.ts.map +1 -1
- package/dist/core/worker.js.map +1 -1
- package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
- package/dist/mcp-server/tools/tool-registration.js +7 -1
- package/dist/mcp-server/tools/tool-registration.js.map +1 -1
- package/dist/mcp-server/tools/utils/deferredInputSchema.d.ts +39 -0
- package/dist/mcp-server/tools/utils/deferredInputSchema.d.ts.map +1 -0
- package/dist/mcp-server/tools/utils/deferredInputSchema.js +33 -0
- package/dist/mcp-server/tools/utils/deferredInputSchema.js.map +1 -0
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +10 -2
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js +25 -3
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
- package/dist/services/speech/providers/whisper.provider.d.ts.map +1 -1
- package/dist/services/speech/providers/whisper.provider.js +4 -2
- package/dist/services/speech/providers/whisper.provider.js.map +1 -1
- package/package.json +21 -20
- package/scripts/check-framework-antipatterns.ts +4 -1
- package/scripts/devcheck.ts +303 -33
- package/scripts/lint-packaging.ts +28 -6
- package/skills/add-tool/SKILL.md +33 -1
- package/skills/api-errors/SKILL.md +2 -1
- package/skills/design-mcp-server/SKILL.md +6 -1
- package/skills/field-test/SKILL.md +70 -30
- package/skills/git-wrapup/SKILL.md +4 -3
- package/templates/package.json +6 -6
package/scripts/devcheck.ts
CHANGED
|
@@ -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
|
-
*
|
|
278
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
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 =
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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)
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
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 {
|
package/skills/add-tool/SKILL.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|