argsbarg 7.1.2 → 7.1.3

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 (58) hide show
  1. package/CHANGELOG.md +24 -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 +14 -1
  29. package/examples/mcp-plugin/README.md +19 -10
  30. package/examples/mcp-plugin/bun.lock +83 -1
  31. package/examples/mcp-plugin/bunfig.toml +4 -0
  32. package/examples/mcp-plugin/docs/node-distro.md +97 -0
  33. package/examples/mcp-plugin/justfile +12 -11
  34. package/examples/mcp-plugin/package.json +2 -1
  35. package/examples/mcp-plugin/scripts/release.ts +10 -11
  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/mcp.json +0 -8
  52. package/examples/mcp-plugin/scripts/mcp.mjs +0 -11106
  53. package/examples/mcp-plugin/src/commands/render-json/__generated__/RenderJsonInputSchema.json +0 -15
  54. package/examples/mcp-plugin/src/commands/render-json/__generated__/index.ts +0 -5
  55. package/examples/mcp-plugin/src/commands/status/__generated__/StatusJsonOutputSchema.json +0 -15
  56. package/examples/mcp-plugin/src/commands/status/__generated__/index.ts +0 -5
  57. package/examples/mcp-plugin/src/commands/workspaces/__generated__/WorkspaceNameInputSchema.json +0 -15
  58. package/examples/mcp-plugin/src/commands/workspaces/__generated__/index.ts +0 -5
@@ -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_]*$/;
@@ -1,7 +1,15 @@
1
- /* Unit tests for shared headless error text (MCP and HTTP). */
1
+ /* Unit tests for shared headless error text (MCP and HTTP) and wrapped MCP tool calls. */
2
2
 
3
3
  import { describe, expect, test } from "bun:test";
4
- import { type HeadlessToolCallFailure, headlessFailureMcpMessage, headlessFailureToHttpResponse } from "./tool-call.ts";
4
+ import { Cli, type CliContext } from "../index.ts";
5
+ import { collectMcpTools } from "../mcp/tools.ts";
6
+ import { requireMcpTool, testProgram } from "../test/fixtures.ts";
7
+ import {
8
+ executeHeadlessToolCall,
9
+ type HeadlessToolCallFailure,
10
+ headlessFailureMcpMessage,
11
+ headlessFailureToHttpResponse,
12
+ } from "./tool-call.ts";
5
13
 
6
14
  /** Builds a failed headless result with the given error message. */
7
15
  function invokeFailure(
@@ -30,3 +38,67 @@ describe("headless error text", () => {
30
38
  expect(body.error).toBe(multiline);
31
39
  });
32
40
  });
41
+
42
+ describe("wrapped MCP tools", () => {
43
+ /** Union input leaf that echoes its inputs; output is an array so structuredContent gets wrapped too. */
44
+ const program = testProgram({
45
+ key: "wrapcall",
46
+ description: "Wrapped call demo.",
47
+ mcpServer: { enabled: true },
48
+ commands: [
49
+ {
50
+ key: "edit",
51
+ description: "Edit.",
52
+ kind: "document",
53
+ inputSchema: {
54
+ anyOf: [{ $ref: "#/definitions/Append" }, { $ref: "#/definitions/Replace" }],
55
+ definitions: {
56
+ Append: {
57
+ type: "object",
58
+ properties: { kind: { const: "append" }, text: { type: "string" } },
59
+ required: ["kind", "text"],
60
+ additionalProperties: false,
61
+ },
62
+ Replace: {
63
+ type: "object",
64
+ properties: { kind: { const: "replace" }, find: { type: "string" }, text: { type: "string" } },
65
+ required: ["kind", "find", "text"],
66
+ additionalProperties: false,
67
+ },
68
+ },
69
+ },
70
+ outputSchema: { type: "array" },
71
+ handler: (ctx: CliContext) => [ctx.inputs],
72
+ },
73
+ ],
74
+ });
75
+ const cli = new Cli(program);
76
+ const tool = requireMcpTool(collectMcpTools(program), "edit");
77
+
78
+ test("unwraps input and wraps structuredContent under result", async () => {
79
+ const result = await executeHeadlessToolCall(cli, tool, { input: { kind: "append", text: "hi" } }, "mcp");
80
+ expect(result.ok).toBe(true);
81
+ if (result.ok) {
82
+ expect(result.mcpResult.structuredContent).toEqual({ result: [{ kind: "append", text: "hi" }] });
83
+ }
84
+ });
85
+
86
+ test("still validates the exact union after unwrapping", async () => {
87
+ const result = await executeHeadlessToolCall(
88
+ cli,
89
+ tool,
90
+ { input: { kind: "append", text: "hi", find: "x" } },
91
+ "mcp",
92
+ );
93
+ expect(result.ok).toBe(false);
94
+ });
95
+
96
+ test("rejects arguments that are not wrapped", async () => {
97
+ const result = await executeHeadlessToolCall(cli, tool, { kind: "append", text: "hi" }, "mcp");
98
+ expect(result.ok).toBe(false);
99
+ if (!result.ok) {
100
+ expect(result.kind).toBe("argv");
101
+ expect(result.message).toContain('single "input" object property');
102
+ }
103
+ });
104
+ });
@@ -10,7 +10,13 @@ import { apiErrorResponse, apiSuccessResponse, stripAnsi } from "../http/result.
10
10
  import { type HttpRouteDef, httpRequestToArgv } from "../http/routes.ts";
11
11
  import { obscureUnexpectedClientMessage } from "../log/emitter.ts";
12
12
  import { buildToolCallSuccessFromResponse } from "../mcp/result.ts";
13
- import { collectMcpTools, type McpToolDef, mcpToolCallToArgv } from "../mcp/tools.ts";
13
+ import {
14
+ collectMcpTools,
15
+ MCP_INPUT_WRAPPER_KEY,
16
+ MCP_OUTPUT_WRAPPER_KEY,
17
+ type McpToolDef,
18
+ mcpToolCallToArgv,
19
+ } from "../mcp/tools.ts";
14
20
  import type { Cli, CliInvokeResult } from "../runtime/cli.ts";
15
21
 
16
22
  /** Outcome of resolving a tool name against the program schema. */
@@ -86,8 +92,31 @@ function noResponseFailure(result: CliInvokeResult): HeadlessToolCallFailure {
86
92
  };
87
93
  }
88
94
 
95
+ /** Pre-invoke argument failure (bad shape or argv conversion error). */
96
+ function argvFailure(
97
+ /** Client-facing error message. */
98
+ message: string,
99
+ ): HeadlessToolCallFailure {
100
+ return { ok: false, kind: "argv", message, exitCode: 1, stdout: "", stderr: "", failureKind: "validation" };
101
+ }
102
+
103
+ /** Returns the leaf input from wrapped tool arguments (`{ input: {...} }`), or `undefined` when malformed. */
104
+ function unwrapToolArgs(
105
+ /** Raw tools/call arguments. */
106
+ args: Record<string, unknown>,
107
+ ): Record<string, unknown> | undefined {
108
+ const inner = args[MCP_INPUT_WRAPPER_KEY];
109
+ const onlyWrapperKey = Object.keys(args).every((key) => key === MCP_INPUT_WRAPPER_KEY);
110
+ if (!onlyWrapperKey || typeof inner !== "object" || inner === null || Array.isArray(inner)) {
111
+ return undefined;
112
+ }
113
+ return inner as Record<string, unknown>;
114
+ }
115
+
89
116
  /**
90
117
  * Converts flat tool arguments to argv and invokes the leaf handler headlessly.
118
+ * Wrapped tools (see `wrapMcpRootSchema`) receive `{ input: {...} }`, unwrapped here, and return
119
+ * `structuredContent` wrapped as `{ result: ... }` to match their `outputSchema`.
91
120
  */
92
121
  export async function executeHeadlessToolCall(
93
122
  cli: Cli,
@@ -96,20 +125,19 @@ export async function executeHeadlessToolCall(
96
125
  invocation: CliInvocation,
97
126
  mcp?: { rpcMethod: string; toolName?: string; requestId: string },
98
127
  ): Promise<HeadlessToolCallResult> {
99
- const argvResult = mcpToolCallToArgv(cli.program, tool, args);
128
+ const leafArgs = tool.inputWrapped ? unwrapToolArgs(args) : args;
129
+ if (leafArgs === undefined) {
130
+ return argvFailure(
131
+ `Tool arguments must be an object with a single "${MCP_INPUT_WRAPPER_KEY}" object property (see inputSchema)`,
132
+ );
133
+ }
134
+
135
+ const argvResult = mcpToolCallToArgv(cli.program, tool, leafArgs);
100
136
  if ("error" in argvResult) {
101
- return {
102
- ok: false,
103
- kind: "argv",
104
- message: argvResult.error,
105
- exitCode: 1,
106
- stdout: "",
107
- stderr: "",
108
- failureKind: "validation",
109
- };
137
+ return argvFailure(argvResult.error);
110
138
  }
111
139
 
112
- const invokeResult = await cli.invoke(argvResult, { invocation, toolArgs: args, mcp });
140
+ const invokeResult = await cli.invoke(argvResult, { invocation, toolArgs: leafArgs, mcp });
113
141
  if (invokeResult.kind === "help") {
114
142
  return invokeFailure(invokeResult);
115
143
  }
@@ -119,7 +147,9 @@ export async function executeHeadlessToolCall(
119
147
  return {
120
148
  ok: true,
121
149
  response: invokeResult.response,
122
- mcpResult,
150
+ mcpResult: tool.outputWrapped
151
+ ? { ...mcpResult, structuredContent: { [MCP_OUTPUT_WRAPPER_KEY]: mcpResult.structuredContent } }
152
+ : mcpResult,
123
153
  };
124
154
  }
125
155
 
@@ -143,15 +173,7 @@ export async function executeHttpRouteCall(
143
173
  ): Promise<HeadlessToolCallResult> {
144
174
  const argvResult = httpRequestToArgv(cli.program, route, pathParams, query, body);
145
175
  if ("error" in argvResult) {
146
- return {
147
- ok: false,
148
- kind: "argv",
149
- message: argvResult.error,
150
- exitCode: 1,
151
- stdout: "",
152
- stderr: "",
153
- failureKind: "validation",
154
- };
176
+ return argvFailure(argvResult.error);
155
177
  }
156
178
 
157
179
  const toolArgs = { ...body, ...query, ...pathParams };
@@ -2,34 +2,12 @@
2
2
  Inline JSON Schema $ref dereferencing for OpenAPI embedding.
3
3
  */
4
4
 
5
- function decodeJsonPointerSegment(segment: string): string {
6
- return segment.replace(/~1/g, "/").replace(/~0/g, "~");
7
- }
5
+ import { resolveJsonPointer } from "../core/json-pointer.ts";
8
6
 
9
7
  function isPlainObject(value: unknown): value is Record<string, unknown> {
10
8
  return value !== null && typeof value === "object" && !Array.isArray(value);
11
9
  }
12
10
 
13
- /** Resolves a same-document JSON Pointer (`#/definitions/Foo`). */
14
- function resolveJsonPointer(root: Record<string, unknown>, ref: string): unknown {
15
- if (!ref.startsWith("#/")) {
16
- return undefined;
17
- }
18
- const segments = ref
19
- .slice(2)
20
- .split("/")
21
- .filter((segment) => segment.length > 0)
22
- .map(decodeJsonPointerSegment);
23
- let current: unknown = root;
24
- for (const segment of segments) {
25
- if (!isPlainObject(current)) {
26
- return undefined;
27
- }
28
- current = current[segment];
29
- }
30
- return current;
31
- }
32
-
33
11
  function derefValue(value: unknown, root: Record<string, unknown>, resolving: Set<string>): unknown {
34
12
  if (Array.isArray(value)) {
35
13
  return value.map((item) => derefValue(item, root, resolving));