argsbarg 7.1.2 → 7.1.4

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 (59) hide show
  1. package/CHANGELOG.md +31 -1
  2. package/README.md +7 -7
  3. package/docs/README.md +1 -1
  4. package/docs/cli-program.md +1 -1
  5. package/docs/configure.md +2 -2
  6. package/docs/mcp.md +20 -0
  7. package/docs/output-schema.md +6 -0
  8. package/examples/full-example/AGENTS.md +1 -1
  9. package/examples/full-example/bun.lock +83 -1
  10. package/examples/full-example/justfile +5 -3
  11. package/examples/full-example-json/AGENTS.md +1 -1
  12. package/examples/full-example-json/README.md +1 -0
  13. package/examples/full-example-json/bun.lock +83 -1
  14. package/examples/full-example-json/docs/cli-schema.json +284 -9
  15. package/examples/full-example-json/docs/cli.md +236 -18
  16. package/examples/full-example-json/docs/http.md +1 -0
  17. package/examples/full-example-json/docs/mcp.md +18 -0
  18. package/examples/full-example-json/docs/openapi.json +155 -0
  19. package/examples/full-example-json/justfile +5 -3
  20. package/examples/full-example-json/src/commands/shape-area/__generated__/ShapeAreaInputSchema.json +59 -0
  21. package/examples/full-example-json/src/commands/shape-area/__generated__/index.ts +5 -0
  22. package/examples/full-example-json/src/commands/shape-area/command.test.ts +34 -0
  23. package/examples/full-example-json/src/commands/shape-area/command.ts +31 -0
  24. package/examples/full-example-json/src/commands/shape-area/types.ts +28 -0
  25. package/examples/full-example-json/src/program.ts +2 -1
  26. package/examples/mcp-plugin/.claude-plugin/plugin.json +7 -1
  27. package/examples/mcp-plugin/.cursor-plugin/plugin.json +7 -1
  28. package/examples/mcp-plugin/AGENTS.md +12 -1
  29. package/examples/mcp-plugin/README.md +18 -9
  30. package/examples/mcp-plugin/bun.lock +83 -1
  31. package/examples/mcp-plugin/dist/mcp-plugin.mjs +385 -0
  32. package/examples/mcp-plugin/justfile +17 -12
  33. package/examples/mcp-plugin/package.json +2 -5
  34. package/examples/mcp-plugin/scripts/release.ts +17 -30
  35. package/examples/mcp-plugin/src/index.ts +0 -1
  36. package/package.json +1 -1
  37. package/src/cli-tool/create.test.ts +14 -0
  38. package/src/cli-tool/create.ts +9 -0
  39. package/src/cli-tool/main.ts +1 -1
  40. package/src/cli-tool/schemagen/run.ts +41 -2
  41. package/src/cli-tool/schemagen/schemagen.test.ts +87 -2
  42. package/src/config/validate.ts +13 -31
  43. package/src/core/json-pointer.ts +46 -0
  44. package/src/core/validate.ts +64 -1
  45. package/src/headless/tool-call.test.ts +74 -2
  46. package/src/headless/tool-call.ts +44 -22
  47. package/src/http/schema-deref.ts +1 -23
  48. package/src/mcp/tools.test.ts +137 -3
  49. package/src/mcp/tools.ts +50 -5
  50. package/examples/mcp-plugin/.mcp.json +0 -6
  51. package/examples/mcp-plugin/bunfig.toml +0 -3
  52. package/examples/mcp-plugin/mcp.json +0 -8
  53. package/examples/mcp-plugin/scripts/mcp.mjs +0 -11106
  54. package/examples/mcp-plugin/src/commands/render-json/__generated__/RenderJsonInputSchema.json +0 -15
  55. package/examples/mcp-plugin/src/commands/render-json/__generated__/index.ts +0 -5
  56. package/examples/mcp-plugin/src/commands/status/__generated__/StatusJsonOutputSchema.json +0 -15
  57. package/examples/mcp-plugin/src/commands/status/__generated__/index.ts +0 -5
  58. package/examples/mcp-plugin/src/commands/workspaces/__generated__/WorkspaceNameInputSchema.json +0 -15
  59. package/examples/mcp-plugin/src/commands/workspaces/__generated__/index.ts +0 -5
@@ -7,12 +7,12 @@ export PATH := justfile_directory() + "/node_modules/.bin:" + env_var("PATH")
7
7
  _:
8
8
  @just --list
9
9
 
10
- # Bundle the standalone Node MCP server script
11
- build:
12
- bun build ./src/index.ts --target=node --outfile=./scripts/mcp.mjs
10
+ # Run schemagen, format, typecheck, and rebuild the committed Node bundle
11
+ check: schemagen format typecheck build
13
12
 
14
- # Run schemagen, typecheck, and format
15
- check: schemagen format typecheck
13
+ # Bundle the Node MCP server that plugins run (commit dist/mcp-plugin.mjs)
14
+ build: schemagen
15
+ bun build ./src/index.ts --target=node --minify-whitespace --minify-syntax --outfile=./dist/mcp-plugin.mjs
16
16
 
17
17
  # demo the HTTP API
18
18
  demo-http:
@@ -52,16 +52,21 @@ format:
52
52
  http:
53
53
  @just run http
54
54
 
55
+ # Lint sources without writing
56
+ lint:
57
+ bun run biome check ./src ./scripts
58
+
59
+ # Install the Claude Code plugin from this checkout (restart Claude Code afterwards)
60
+ plugin-claude-install: build
61
+ claude plugin marketplace add "$(pwd)"
62
+ claude plugin install mcp-plugin@mcp-plugin
63
+
55
64
  # Link plugin into ~/.cursor/plugins/local/mcp-plugin for local testing
56
- install-plugin-cursor: build
65
+ plugin-cursor-upsert: build
57
66
  mkdir -p ~/.cursor/plugins/local
58
67
  ln -sfn '{{justfile_directory()}}' ~/.cursor/plugins/local/mcp-plugin
59
68
  @echo "Linked MCP plugin to ~/.cursor/plugins/local/mcp-plugin"
60
69
 
61
- # Lint sources without writing
62
- lint:
63
- bun run biome check ./src ./scripts
64
-
65
70
  # Run the CLI from source once
66
71
  run *ARGS:
67
72
  bun ./src/index.ts {{ARGS}}
@@ -73,14 +78,14 @@ schemagen:
73
78
  # Install bun/npm dependencies and generate schemas
74
79
  setup:
75
80
  bun install
76
- test -f node_modules/argsbarg/bin/argsbarg && ln -sf ../argsbarg/bin/argsbarg node_modules/.bin/argsbarg
81
+ test -f node_modules/argsbarg/bin/argsbarg && ln -sf ../argsbarg/bin/argsbarg node_modules/.bin/argsbarg # argsbarg-dev-only: fix link for file:../.. installs
77
82
  just schemagen
78
83
 
79
84
  # Run unit tests (after check)
80
85
  test: check
81
86
  bun test .
82
87
 
83
- # Bump version, build, publish release
88
+ # Bump version, rebuild the bundle, and publish a release
84
89
  release *ARGS:
85
90
  bun scripts/release.ts {{ARGS}}
86
91
 
@@ -6,11 +6,8 @@
6
6
  "module": "src/index.ts",
7
7
  "description": "Argsbarg MCP plugin template for Cursor and Claude Code marketplaces.",
8
8
  "engines": {
9
- "bun": ">=1.3"
10
- },
11
- "scripts": {
12
- "biome": "biome",
13
- "start": "bun run src/index.ts"
9
+ "bun": ">=1.3",
10
+ "node": ">=20"
14
11
  },
15
12
  "dependencies": {
16
13
  "argsbarg": "file:../.."
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  /*
3
- Bump version, build standalone Node MCP server, and publish release tag.
3
+ Bump version, rebuild the committed Node bundle, and publish a plugin release tag.
4
4
  */
5
5
 
6
6
  import * as fs from "node:fs";
@@ -39,10 +39,7 @@ async function main(): Promise<void> {
39
39
 
40
40
  /** Prints usage and exits. */
41
41
  function usage(): never {
42
- process.stderr.write(
43
- "Usage:\n" +
44
- " bun scripts/release.ts <major|minor|patch> [--yes] [--dry-run]\n",
45
- );
42
+ process.stderr.write("Usage:\n" + " bun scripts/release.ts <major|minor|patch> [--yes] [--dry-run]\n");
46
43
  process.exit(1);
47
44
  }
48
45
 
@@ -76,26 +73,28 @@ async function runRelease(
76
73
  /** Parsed CLI options. */
77
74
  options: ReleaseOptions,
78
75
  ): Promise<void> {
76
+ const currentVersion = readCurrentVersion();
77
+ const newVersion = applyBump(currentVersion, bump);
78
+ if (options.dryRun) {
79
+ console.log(
80
+ `[dry-run] Would run checks, bump ${currentVersion} to ${newVersion}, update the changelog, regenerate docs, rebuild the bundle, commit, tag v${newVersion}, push, and create a GitHub release.`,
81
+ );
82
+ return;
83
+ }
84
+
79
85
  const testResult = await $`just test`.nothrow();
80
86
  if (testResult.exitCode !== 0) process.exit(testResult.exitCode);
81
87
 
82
- const currentVersion = readCurrentVersion();
83
- const newVersion = applyBump(currentVersion, bump);
84
88
  console.log(`Releasing ${currentVersion} → ${newVersion}`);
85
89
 
86
90
  updateVersion(newVersion);
87
91
  updateChangelog(newVersion);
88
92
 
89
- const buildResult = await $`just build`.nothrow();
90
- if (buildResult.exitCode !== 0) process.exit(buildResult.exitCode);
91
-
92
93
  const docgenResult = await $`just docgen`.nothrow();
93
94
  if (docgenResult.exitCode !== 0) process.exit(docgenResult.exitCode);
94
95
 
95
- if (options.dryRun) {
96
- console.log(`[dry-run] Would commit, tag v${newVersion}, and create GitHub release.`);
97
- return;
98
- }
96
+ const buildResult = await $`just build`.nothrow();
97
+ if (buildResult.exitCode !== 0) process.exit(buildResult.exitCode);
99
98
 
100
99
  await commitAndTag(newVersion);
101
100
  await createGithubRelease(`v${newVersion}`);
@@ -154,31 +153,19 @@ function updateVersion(
154
153
  ): void {
155
154
  const pkgPath = "package.json";
156
155
  const pkgContent = fs.readFileSync(pkgPath, "utf-8");
157
- fs.writeFileSync(
158
- pkgPath,
159
- pkgContent.replace(/"version":\s*"[^"]+"/, `"version": "${newVersion}"`),
160
- );
156
+ fs.writeFileSync(pkgPath, pkgContent.replace(/"version":\s*"[^"]+"/, `"version": "${newVersion}"`));
161
157
 
162
158
  const progContent = fs.readFileSync(programPath, "utf-8");
163
- fs.writeFileSync(
164
- programPath,
165
- progContent.replace(/version:\s*"[^"]+"/, `version: "${newVersion}"`),
166
- );
159
+ fs.writeFileSync(programPath, progContent.replace(/version:\s*"[^"]+"/, `version: "${newVersion}"`));
167
160
 
168
161
  if (fs.existsSync(cursorManifestPath)) {
169
162
  const cursorContent = fs.readFileSync(cursorManifestPath, "utf-8");
170
- fs.writeFileSync(
171
- cursorManifestPath,
172
- cursorContent.replace(/"version":\s*"[^"]+"/, `"version": "${newVersion}"`),
173
- );
163
+ fs.writeFileSync(cursorManifestPath, cursorContent.replace(/"version":\s*"[^"]+"/, `"version": "${newVersion}"`));
174
164
  }
175
165
 
176
166
  if (fs.existsSync(claudeManifestPath)) {
177
167
  const claudeContent = fs.readFileSync(claudeManifestPath, "utf-8");
178
- fs.writeFileSync(
179
- claudeManifestPath,
180
- claudeContent.replace(/"version":\s*"[^"]+"/, `"version": "${newVersion}"`),
181
- );
168
+ fs.writeFileSync(claudeManifestPath, claudeContent.replace(/"version":\s*"[^"]+"/, `"version": "${newVersion}"`));
182
169
  }
183
170
  }
184
171
 
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env bun
2
1
  /*
3
2
  Thin CLI entry — delegates to argsbarg runtime.
4
3
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argsbarg",
3
- "version": "7.1.2",
3
+ "version": "7.1.4",
4
4
  "main": "./src/index.ts",
5
5
  "module": "./src/index.ts",
6
6
  "dependencies": {
@@ -10,6 +10,7 @@ import {
10
10
  applyCreate,
11
11
  type CreateOptions,
12
12
  classNameFromKey,
13
+ DEV_ONLY_MARKER,
13
14
  diffCreate,
14
15
  diffCreateDetails,
15
16
  renderCreateTree,
@@ -38,6 +39,19 @@ function baseOpts(overrides: Partial<CreateOptions> = {}): CreateOptions {
38
39
 
39
40
  /** Tests for argsbarg create. */
40
41
  describe("argsbarg create", () => {
42
+ test("in-repo example templates match their own create output", () => {
43
+ for (const example of ["full-example", "full-example-json", "mcp-plugin"]) {
44
+ const dir = join(import.meta.dir, "../../examples", example);
45
+ expect({ example, drift: diffCreate(dir, { check: true }) }).toEqual({ example, drift: [] });
46
+ }
47
+ });
48
+
49
+ test("drops argsbarg-dev-only lines outside the in-repo template", () => {
50
+ const content = `setup:\n bun install\n ln -sf a b ${DEV_ONLY_MARKER}: fix link\n just schemagen\n`;
51
+ expect(substituteTemplateContent(content, baseOpts())).toBe("setup:\n bun install\n just schemagen\n");
52
+ expect(substituteTemplateContent(content, baseOpts({ devTemplate: true }))).toBe(content);
53
+ });
54
+
41
55
  test("substitutes {key} tokens", () => {
42
56
  const out = substituteTemplateContent(
43
57
  "key={key} class={className} env={envPrefix}_API_TOKEN tap={tap} org={tapOrg}",
@@ -263,6 +263,12 @@ function tokenMap(opts: CreateOptions): Record<string, string> {
263
263
  };
264
264
  }
265
265
 
266
+ /**
267
+ * Trailing comment marking a template line that only makes sense inside the argsbarg repo (e.g. the
268
+ * `.bin/argsbarg` symlink fix for `file:../..` installs). `create` drops these lines from new projects.
269
+ */
270
+ export const DEV_ONLY_MARKER = "# argsbarg-dev-only";
271
+
266
272
  /** Substitute \`{key}\`-style placeholders; also replace template identity literals. */
267
273
  export function substituteTemplateContent(content: string, opts: CreateOptions): string {
268
274
  const tmpl = templateIdentity(opts.templateId);
@@ -303,6 +309,9 @@ export function substituteTemplateContent(content: string, opts: CreateOptions):
303
309
 
304
310
  if (!opts.devTemplate) {
305
311
  out = out
312
+ .split("\n")
313
+ .filter((line) => !line.includes(DEV_ONLY_MARKER))
314
+ .join("\n")
306
315
  .replace(/"argsbarg":\s*"workspace:\*"/, `"argsbarg": "^${argsbargVersion}"`)
307
316
  .replace(/"argsbarg":\s*"file:\.\.\/\.\."/, `"argsbarg": "^${argsbargVersion}"`);
308
317
  }
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- /** Argsbarg package CLI (`bunx argsbarg`) — bootstrap and tooling; library API is `import from "argsbarg"`. */
2
+ /** Argsbarg package CLI (`bun x argsbarg`) — bootstrap and tooling; library API is `import from "argsbarg"`. */
3
3
 
4
4
  import { Cli } from "../index.ts";
5
5
  import { program } from "./program.ts";
@@ -5,6 +5,7 @@ Generate JSON Schema artifacts under __generated__/ and write index.ts re-export
5
5
  import { mkdirSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join, relative } from "node:path";
7
7
  import { createGenerator } from "ts-json-schema-generator";
8
+ import { resolveJsonPointer } from "../../core/json-pointer.ts";
8
9
  import { cleanStaleGenerated } from "./cleanup.ts";
9
10
  import { discoverSchemaRoots, type SchemaRoot } from "./discover-schema-roots.ts";
10
11
  import { GENERATED_DIR, schemaExportName, schemaJsonBasename, schemaJsonImportVar } from "./names.ts";
@@ -46,11 +47,49 @@ function generateJson(projectRoot: string, tsconfigPath: string, root: SchemaRoo
46
47
  jsDoc: "extended",
47
48
  additionalProperties: false,
48
49
  });
49
- const schema = generator.createSchema(root.typeName) as Record<string, unknown>;
50
- schema.additionalProperties = false;
50
+ const schema = hoistRootRef(generator.createSchema(root.typeName) as Record<string, unknown>, root.typeName);
51
+ // Only object roots: on a union root with no root `properties`, this would reject every property.
52
+ if (schema.type === "object") {
53
+ schema.additionalProperties = false;
54
+ }
51
55
  return schema;
52
56
  }
53
57
 
58
+ /**
59
+ * Replaces a root `$ref` with the definition it names, following alias chains
60
+ * (`export type Input = Inner` emits `{ $ref: "#/definitions/Inner" }` even with `topRef: false`).
61
+ * Keeps `definitions` so nested and recursive references still resolve.
62
+ */
63
+ function hoistRootRef(
64
+ /** Schema from ts-json-schema-generator. */
65
+ schema: Record<string, unknown>,
66
+ /** Root type name, for error messages. */
67
+ typeName: string,
68
+ ): Record<string, unknown> {
69
+ let target = schema;
70
+ const seen = new Set<string>();
71
+ while (typeof target.$ref === "string") {
72
+ const ref = target.$ref;
73
+ if (seen.has(ref)) {
74
+ throw new Error(`schemagen: ${typeName} root $ref cycle at ${ref}`);
75
+ }
76
+ seen.add(ref);
77
+ const next = resolveJsonPointer(schema, ref);
78
+ if (typeof next !== "object" || next === null || Array.isArray(next)) {
79
+ throw new Error(`schemagen: ${typeName} root $ref does not resolve: ${ref}`);
80
+ }
81
+ target = next as Record<string, unknown>;
82
+ }
83
+ if (target === schema) {
84
+ return schema;
85
+ }
86
+ return {
87
+ ...(schema.$schema === undefined ? {} : { $schema: schema.$schema }),
88
+ ...target,
89
+ ...(schema.definitions === undefined ? {} : { definitions: schema.definitions }),
90
+ };
91
+ }
92
+
54
93
  function writeGeneratedIndex(generatedDir: string, roots: SchemaRoot[]): void {
55
94
  const sorted = [...roots].sort((left, right) => left.typeName.localeCompare(right.typeName));
56
95
  const lines = ["// Auto-generated by argsbarg schemagen — do not edit by hand.", ""];
@@ -48,7 +48,12 @@ function writeSrcFile(root: string, relPath: string, body: string): void {
48
48
  describe("schemagen", () => {
49
49
  test("discovers @sg types in full-example-json", () => {
50
50
  const roots = discoverSchemaRoots(exampleRoot);
51
- expect(roots.map((r) => r.typeName).sort()).toEqual(["RenderJsonInput", "StatusJsonOutput", "WorkspaceNameInput"]);
51
+ expect(roots.map((r) => r.typeName).sort()).toEqual([
52
+ "RenderJsonInput",
53
+ "ShapeAreaInput",
54
+ "StatusJsonOutput",
55
+ "WorkspaceNameInput",
56
+ ]);
52
57
  });
53
58
 
54
59
  test("maps type names to __generated__ filenames and export names", () => {
@@ -58,7 +63,7 @@ describe("schemagen", () => {
58
63
 
59
64
  test("runSchemagen writes __generated__ artifacts in full-example-json", () => {
60
65
  const counts = runSchemagen({ projectRoot: exampleRoot });
61
- expect(counts).toEqual({ schemas: 3 });
66
+ expect(counts).toEqual({ schemas: 4 });
62
67
  });
63
68
 
64
69
  test("discovers @sg roots and writes named schema artifacts", () => {
@@ -230,4 +235,84 @@ export interface DupType {
230
235
 
231
236
  expect(() => discoverSchemaRoots(root)).toThrow("duplicate schema root type DupType");
232
237
  });
238
+
239
+ describe("root normalization", () => {
240
+ /** Writes one `@sg` type (plus helper types) and returns its generated schema. */
241
+ function generate(typeName: string, body: string): Record<string, unknown> {
242
+ const root = makeTempProject();
243
+ writeSrcFile(root, "src/commands/demo/types.ts", body);
244
+ runSchemagen({ projectRoot: root });
245
+ const path = join(root, "src/commands/demo/__generated__", schemaJsonBasename(typeName));
246
+ return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
247
+ }
248
+
249
+ test("hoists an alias root $ref to an object root", () => {
250
+ const schema = generate(
251
+ "AliasInput",
252
+ `export interface Inner {
253
+ name: string;
254
+ }
255
+ /** @sg */
256
+ export type AliasInput = Inner;
257
+ `,
258
+ );
259
+ expect(schema.$ref).toBeUndefined();
260
+ expect(schema.type).toBe("object");
261
+ expect(schema.properties).toEqual({ name: { type: "string" } });
262
+ expect(schema.additionalProperties).toBe(false);
263
+ });
264
+
265
+ test("follows alias chains and percent-encoded generic refs", () => {
266
+ const chain = generate(
267
+ "ChainInput",
268
+ `export interface Inner {
269
+ name: string;
270
+ }
271
+ export type Mid = Inner;
272
+ /** @sg */
273
+ export type ChainInput = Mid;
274
+ `,
275
+ );
276
+ expect(chain.type).toBe("object");
277
+ expect(chain.properties).toEqual({ name: { type: "string" } });
278
+
279
+ const generic = generate(
280
+ "GenericInput",
281
+ `export interface Box<T> {
282
+ value: T;
283
+ }
284
+ /** @sg */
285
+ export type GenericInput = Box<string>;
286
+ `,
287
+ );
288
+ expect(generic.type).toBe("object");
289
+ expect(generic.properties).toEqual({ value: { type: "string" } });
290
+ });
291
+
292
+ test("keeps definitions so recursive references still resolve", () => {
293
+ const schema = generate(
294
+ "TreeInput",
295
+ `export interface Tree {
296
+ name: string;
297
+ children?: Tree[];
298
+ }
299
+ /** @sg */
300
+ export type TreeInput = Tree;
301
+ `,
302
+ );
303
+ expect(schema.type).toBe("object");
304
+ expect((schema.definitions as Record<string, unknown>).Tree).toBeDefined();
305
+ });
306
+
307
+ test("leaves union roots without a root additionalProperties", () => {
308
+ const schema = generate(
309
+ "UnionInput",
310
+ `/** @sg */
311
+ export type UnionInput = { kind: "a"; a: string } | { kind: "b"; b: number };
312
+ `,
313
+ );
314
+ expect(Array.isArray(schema.anyOf)).toBe(true);
315
+ expect(schema.additionalProperties).toBeUndefined();
316
+ });
317
+ });
233
318
  });
@@ -5,6 +5,7 @@ CLI value coercion for configure set remains here (comma-separated arrays, boole
5
5
 
6
6
  import { format as jsonSchemaFormats, type Schema, type SchemaDraft, Validator } from "@cfworker/json-schema";
7
7
  import { parseCommaList, parseDate, parseDateTime, validateCommaList } from "../core/formats.ts";
8
+ import { decodeJsonPointerSegment, resolveJsonPointer } from "../core/json-pointer.ts";
8
9
  import { isFrameworkConfigKey } from "./bindings.ts";
9
10
 
10
11
  type JsonSchema = Record<string, unknown>;
@@ -72,10 +73,6 @@ function formatInstancePath(instanceLocation: string): string {
72
73
  return instanceLocation;
73
74
  }
74
75
 
75
- function decodeJsonPointerSegment(segment: string): string {
76
- return segment.replace(/~1/g, "/").replace(/~0/g, "~");
77
- }
78
-
79
76
  /** cfworker keywords that only wrap a deeper, more specific failure — dropped when one survives underneath. */
80
77
  const WRAPPER_KEYWORDS = new Set([
81
78
  "$ref",
@@ -235,8 +232,12 @@ function unionDiscriminator(branches: unknown[], root: JsonSchema): UnionDiscrim
235
232
  if (eligible.length === 0) {
236
233
  return undefined;
237
234
  }
238
- const prop = eligible.includes("kind") ? "kind" : eligible.includes("type") ? "type" : [...eligible].sort()[0]!;
239
- return { prop, valuesByBranch: branchValuesFor(prop)! };
235
+ const prop = eligible.includes("kind") ? "kind" : eligible.includes("type") ? "type" : [...eligible].sort()[0];
236
+ const valuesByBranch = prop === undefined ? undefined : branchValuesFor(prop);
237
+ if (prop === undefined || valuesByBranch === undefined) {
238
+ return undefined;
239
+ }
240
+ return { prop, valuesByBranch };
240
241
  }
241
242
 
242
243
  /** Sorted, comma-joined, unquoted list of values for error messages. */
@@ -248,7 +249,7 @@ function joinSorted(values: Iterable<string>): string {
248
249
  function rewriteErrorMessage(err: RawError, root: JsonSchema): string {
249
250
  const additionalPropsMatch = /^Property "(.+)" does not match additional properties schema\.$/.exec(err.error);
250
251
  if (additionalPropsMatch) {
251
- const name = additionalPropsMatch[1]!;
252
+ const name = additionalPropsMatch[1] ?? "";
252
253
  const parentLoc = parentPointer(err.keywordLocation);
253
254
  const parentSchema = parentLoc ? schemaAtPointer(root, parentLoc) : undefined;
254
255
  const props =
@@ -268,7 +269,7 @@ function rewriteErrorMessage(err: RawError, root: JsonSchema): string {
268
269
  const enumMatch = /^Instance does not match any of (\[.*\])\.$/.exec(err.error);
269
270
  if (enumMatch) {
270
271
  try {
271
- const values = JSON.parse(enumMatch[1]!) as unknown[];
272
+ const values = JSON.parse(enumMatch[1] ?? "") as unknown[];
272
273
  return `must be one of: ${values.map((v) => String(v)).join(", ")}`;
273
274
  } catch {
274
275
  // fall through to the raw message
@@ -394,16 +395,16 @@ function narrowUnionErrors(errors: RawError[], root: JsonSchema, data: unknown):
394
395
  for (const err of errors) {
395
396
  if (dropped.has(err)) continue;
396
397
  if (err.keyword === "additionalProperties") {
397
- const match = /^Property "(.+)" does not match additional properties schema\.$/.exec(err.error);
398
+ const name = /^Property "(.+)" does not match additional properties schema\.$/.exec(err.error)?.[1];
398
399
  const parentLoc = parentPointer(err.keywordLocation);
399
400
  const parentSchema = parentLoc ? schemaAtPointer(root, parentLoc) : undefined;
400
401
  const props =
401
- match && parentSchema && typeof parentSchema === "object" && !Array.isArray(parentSchema)
402
+ name !== undefined && parentSchema && typeof parentSchema === "object" && !Array.isArray(parentSchema)
402
403
  ? (parentSchema as JsonSchema).properties
403
404
  : undefined;
404
405
  const declared =
405
- match && props && typeof props === "object" && !Array.isArray(props)
406
- ? Object.hasOwn(props as JsonSchema, match[1]!)
406
+ name !== undefined && props && typeof props === "object" && !Array.isArray(props)
407
+ ? Object.hasOwn(props as JsonSchema, name)
407
408
  : false;
408
409
  if (declared) dropped.add(err);
409
410
  continue;
@@ -450,25 +451,6 @@ export function resolveSchemaDraft(schema: JsonSchema): SchemaDraft {
450
451
  return "7";
451
452
  }
452
453
 
453
- function resolveJsonPointer(root: JsonSchema, ref: string): unknown {
454
- if (!ref.startsWith("#/")) {
455
- return undefined;
456
- }
457
- const segments = ref
458
- .slice(2)
459
- .split("/")
460
- .filter((segment) => segment.length > 0)
461
- .map(decodeJsonPointerSegment);
462
- let current: unknown = root;
463
- for (const segment of segments) {
464
- if (typeof current !== "object" || current === null || Array.isArray(current)) {
465
- return undefined;
466
- }
467
- current = (current as Record<string, unknown>)[segment];
468
- }
469
- return current;
470
- }
471
-
472
454
  function attachRootCompanionSchemas(validator: Validator, root: JsonSchema, active: JsonSchema): void {
473
455
  if (active === root) {
474
456
  return;
@@ -0,0 +1,46 @@
1
+ /*
2
+ Same-document JSON Pointer resolution for JSON Schema `$ref` values (`#/definitions/Foo`).
3
+ Shared by schemagen root hoisting, MCP tool schema checks, config validation, and OpenAPI dereferencing.
4
+ */
5
+
6
+ /**
7
+ * Decodes one JSON Pointer segment: URI percent-escapes first (ts-json-schema-generator writes
8
+ * `#/definitions/Box%3Cstring%3E`), then the `~1` → `/` and `~0` → `~` pointer escapes.
9
+ */
10
+ export function decodeJsonPointerSegment(
11
+ /** Raw segment between `/` separators. */
12
+ segment: string,
13
+ ): string {
14
+ let decoded = segment;
15
+ try {
16
+ decoded = decodeURIComponent(segment);
17
+ } catch {
18
+ // Malformed percent-escape: fall back to the raw segment.
19
+ }
20
+ return decoded.replace(/~1/g, "/").replace(/~0/g, "~");
21
+ }
22
+
23
+ /** Resolves a same-document JSON Pointer (`#/definitions/Foo`) against `root`; `undefined` when it does not resolve. */
24
+ export function resolveJsonPointer(
25
+ /** Document the pointer is resolved against (the schema root). */
26
+ root: unknown,
27
+ /** `$ref` value; only `#/…` pointers are resolved. */
28
+ ref: string,
29
+ ): unknown {
30
+ if (!ref.startsWith("#/")) {
31
+ return undefined;
32
+ }
33
+ const segments = ref
34
+ .slice(2)
35
+ .split("/")
36
+ .filter((segment) => segment.length > 0)
37
+ .map(decodeJsonPointerSegment);
38
+ let current: unknown = root;
39
+ for (const segment of segments) {
40
+ if (typeof current !== "object" || current === null || Array.isArray(current)) {
41
+ return undefined;
42
+ }
43
+ current = (current as Record<string, unknown>)[segment];
44
+ }
45
+ return current;
46
+ }
@@ -5,9 +5,10 @@ This module validates CLI schemas before execution.
5
5
  import { reservedDocsTopicResourceUris } from "../docs/mcp-resources.ts";
6
6
  import { DOCS_BUILTIN_TOPIC_KEYS, docsEnabled } from "../docs/resolve.ts";
7
7
  import { HTTP_RESERVED_TOP_LEVEL_SEGMENTS } from "../http/paths.ts";
8
- import { resolveMcpSchemaUri } from "../mcp/tools.ts";
8
+ import { collectMcpTools, resolveMcpSchemaUri } from "../mcp/tools.ts";
9
9
  import { reservedCommandNames, resolveCapabilities } from "../runtime/capabilities.ts";
10
10
  import { validateFormatValue } from "./formats.ts";
11
+ import { resolveJsonPointer } from "./json-pointer.ts";
11
12
  import {
12
13
  type CliLeaf,
13
14
  type CliNode,
@@ -196,6 +197,68 @@ export function cliValidateProgram(program: CliProgram): void {
196
197
  }
197
198
 
198
199
  walkNode(program, program, true);
200
+
201
+ if (caps.mcp) {
202
+ validateMcpToolSchemas(program);
203
+ }
204
+ }
205
+
206
+ /** Keywords whose values are instance data, not subschemas; a `$ref` string inside them is not a reference. */
207
+ const SCHEMA_DATA_KEYWORDS = new Set(["const", "default", "enum", "examples"]);
208
+
209
+ /** Collects every `$ref` string in a schema (skipping instance-data keywords). */
210
+ function collectSchemaRefs(
211
+ /** Schema fragment to walk. */
212
+ node: unknown,
213
+ /** Accumulator for found `$ref` values. */
214
+ out: string[],
215
+ ): string[] {
216
+ if (Array.isArray(node)) {
217
+ for (const item of node) {
218
+ collectSchemaRefs(item, out);
219
+ }
220
+ } else if (typeof node === "object" && node !== null) {
221
+ for (const [key, value] of Object.entries(node)) {
222
+ if (key === "$ref" && typeof value === "string") {
223
+ out.push(value);
224
+ } else if (!SCHEMA_DATA_KEYWORDS.has(key)) {
225
+ collectSchemaRefs(value, out);
226
+ }
227
+ }
228
+ }
229
+ return out;
230
+ }
231
+
232
+ /**
233
+ * Checks the schemas MCP clients will see (after object-root wrapping in `collectMcpTools`):
234
+ * every local `$ref` must resolve, and wrapped schemas cannot use `$ref: "#"` (it would point at the wrapper).
235
+ */
236
+ function validateMcpToolSchemas(
237
+ /** Program with `mcpServer.enabled`. */
238
+ program: CliProgram,
239
+ ): void {
240
+ for (const tool of collectMcpTools(program)) {
241
+ const schemas = [
242
+ { label: "inputSchema", schema: tool.inputSchema, wrapped: tool.inputWrapped },
243
+ { label: "outputSchema", schema: tool.outputSchema, wrapped: tool.outputWrapped },
244
+ ];
245
+ for (const { label, schema, wrapped } of schemas) {
246
+ if (schema === undefined) {
247
+ continue;
248
+ }
249
+ for (const ref of collectSchemaRefs(schema, [])) {
250
+ if (ref === "#" && wrapped) {
251
+ throw new CliSchemaValidationError(
252
+ `MCP tool "${tool.name}" ${label} uses $ref "#" but its root is not type "object", so MCP wraps it; ` +
253
+ "reference a named definition instead",
254
+ );
255
+ }
256
+ if (ref.startsWith("#/") && resolveJsonPointer(schema, ref) === undefined) {
257
+ throw new CliSchemaValidationError(`MCP tool "${tool.name}" ${label} has an unresolved $ref: ${ref}`);
258
+ }
259
+ }
260
+ }
261
+ }
199
262
  }
200
263
 
201
264
  const PARAM_ROUTER_KEY = /^:[a-zA-Z][a-zA-Z0-9_]*$/;