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.
- package/CHANGELOG.md +31 -1
- package/README.md +7 -7
- package/docs/README.md +1 -1
- package/docs/cli-program.md +1 -1
- package/docs/configure.md +2 -2
- package/docs/mcp.md +20 -0
- package/docs/output-schema.md +6 -0
- package/examples/full-example/AGENTS.md +1 -1
- package/examples/full-example/bun.lock +83 -1
- package/examples/full-example/justfile +5 -3
- package/examples/full-example-json/AGENTS.md +1 -1
- package/examples/full-example-json/README.md +1 -0
- package/examples/full-example-json/bun.lock +83 -1
- package/examples/full-example-json/docs/cli-schema.json +284 -9
- package/examples/full-example-json/docs/cli.md +236 -18
- package/examples/full-example-json/docs/http.md +1 -0
- package/examples/full-example-json/docs/mcp.md +18 -0
- package/examples/full-example-json/docs/openapi.json +155 -0
- package/examples/full-example-json/justfile +5 -3
- package/examples/full-example-json/src/commands/shape-area/__generated__/ShapeAreaInputSchema.json +59 -0
- package/examples/full-example-json/src/commands/shape-area/__generated__/index.ts +5 -0
- package/examples/full-example-json/src/commands/shape-area/command.test.ts +34 -0
- package/examples/full-example-json/src/commands/shape-area/command.ts +31 -0
- package/examples/full-example-json/src/commands/shape-area/types.ts +28 -0
- package/examples/full-example-json/src/program.ts +2 -1
- package/examples/mcp-plugin/.claude-plugin/plugin.json +7 -1
- package/examples/mcp-plugin/.cursor-plugin/plugin.json +7 -1
- package/examples/mcp-plugin/AGENTS.md +12 -1
- package/examples/mcp-plugin/README.md +18 -9
- package/examples/mcp-plugin/bun.lock +83 -1
- package/examples/mcp-plugin/dist/mcp-plugin.mjs +385 -0
- package/examples/mcp-plugin/justfile +17 -12
- package/examples/mcp-plugin/package.json +2 -5
- package/examples/mcp-plugin/scripts/release.ts +17 -30
- package/examples/mcp-plugin/src/index.ts +0 -1
- package/package.json +1 -1
- package/src/cli-tool/create.test.ts +14 -0
- package/src/cli-tool/create.ts +9 -0
- package/src/cli-tool/main.ts +1 -1
- package/src/cli-tool/schemagen/run.ts +41 -2
- package/src/cli-tool/schemagen/schemagen.test.ts +87 -2
- package/src/config/validate.ts +13 -31
- package/src/core/json-pointer.ts +46 -0
- package/src/core/validate.ts +64 -1
- package/src/headless/tool-call.test.ts +74 -2
- package/src/headless/tool-call.ts +44 -22
- package/src/http/schema-deref.ts +1 -23
- package/src/mcp/tools.test.ts +137 -3
- package/src/mcp/tools.ts +50 -5
- package/examples/mcp-plugin/.mcp.json +0 -6
- package/examples/mcp-plugin/bunfig.toml +0 -3
- package/examples/mcp-plugin/mcp.json +0 -8
- package/examples/mcp-plugin/scripts/mcp.mjs +0 -11106
- package/examples/mcp-plugin/src/commands/render-json/__generated__/RenderJsonInputSchema.json +0 -15
- package/examples/mcp-plugin/src/commands/render-json/__generated__/index.ts +0 -5
- package/examples/mcp-plugin/src/commands/status/__generated__/StatusJsonOutputSchema.json +0 -15
- package/examples/mcp-plugin/src/commands/status/__generated__/index.ts +0 -5
- package/examples/mcp-plugin/src/commands/workspaces/__generated__/WorkspaceNameInputSchema.json +0 -15
- package/examples/mcp-plugin/src/commands/workspaces/__generated__/index.ts +0 -5
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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:
|
|
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 };
|
package/src/http/schema-deref.ts
CHANGED
|
@@ -2,34 +2,12 @@
|
|
|
2
2
|
Inline JSON Schema $ref dereferencing for OpenAPI embedding.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
|
|
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));
|
package/src/mcp/tools.test.ts
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
/*
|
|
2
|
-
Tests for mcp/tools module: MCP tool derivation, size reporting,
|
|
2
|
+
Tests for mcp/tools module: MCP tool derivation, size reporting, per-leaf MCP-only notes,
|
|
3
|
+
object-root schema wrapping, and the MCP tool schema startup check.
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
6
|
import { describe, expect, test } from "bun:test";
|
|
6
7
|
import { cliPresentationRoot } from "../builtins/presentation.ts";
|
|
7
8
|
import { CliOptionKind } from "../core/types.ts";
|
|
9
|
+
import { cliValidateProgram } from "../core/validate.ts";
|
|
8
10
|
import { cliHelpRender } from "../help.ts";
|
|
9
|
-
import { testProgram } from "../test/fixtures.ts";
|
|
10
|
-
import {
|
|
11
|
+
import { requireMcpTool, testProgram } from "../test/fixtures.ts";
|
|
12
|
+
import {
|
|
13
|
+
collectMcpTools,
|
|
14
|
+
DEFAULT_MCP_SIZE_LIMITS,
|
|
15
|
+
MCP_INPUT_WRAPPER_KEY,
|
|
16
|
+
mcpSizeReport,
|
|
17
|
+
wrapMcpRootSchema,
|
|
18
|
+
} from "./tools.ts";
|
|
11
19
|
|
|
12
20
|
describe("mcpSizeReport", () => {
|
|
13
21
|
test("measures description and pretty definition against a hand-built JSON.stringify", () => {
|
|
@@ -156,3 +164,129 @@ describe("mcpTool.notes", () => {
|
|
|
156
164
|
expect(help).toContain("Original CLI notes.");
|
|
157
165
|
});
|
|
158
166
|
});
|
|
167
|
+
|
|
168
|
+
/** Discriminated-union input: `anyOf` root with `$ref` branches, as ts-json-schema-generator writes it. */
|
|
169
|
+
const unionInputSchema: Record<string, unknown> = {
|
|
170
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
171
|
+
description: "Edit operation.",
|
|
172
|
+
anyOf: [{ $ref: "#/definitions/Append" }, { $ref: "#/definitions/Replace" }],
|
|
173
|
+
definitions: {
|
|
174
|
+
Append: {
|
|
175
|
+
type: "object",
|
|
176
|
+
properties: { kind: { const: "append" }, text: { type: "string" } },
|
|
177
|
+
required: ["kind", "text"],
|
|
178
|
+
additionalProperties: false,
|
|
179
|
+
},
|
|
180
|
+
Replace: {
|
|
181
|
+
type: "object",
|
|
182
|
+
properties: { kind: { const: "replace" }, find: { type: "string" }, text: { type: "string" } },
|
|
183
|
+
required: ["kind", "find", "text"],
|
|
184
|
+
additionalProperties: false,
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
describe("MCP object-root wrapping", () => {
|
|
190
|
+
test("object-rooted schemas pass through unchanged", () => {
|
|
191
|
+
const schema = { type: "object", properties: { a: { type: "string" } } };
|
|
192
|
+
expect(wrapMcpRootSchema(schema, MCP_INPUT_WRAPPER_KEY)).toEqual({ schema, wrapped: false });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("union roots wrap under input with $schema and definitions moved to the new root", () => {
|
|
196
|
+
const { schema, wrapped } = wrapMcpRootSchema(unionInputSchema, MCP_INPUT_WRAPPER_KEY);
|
|
197
|
+
expect(wrapped).toBe(true);
|
|
198
|
+
expect(schema).toEqual({
|
|
199
|
+
$schema: unionInputSchema.$schema,
|
|
200
|
+
type: "object",
|
|
201
|
+
properties: { input: { description: "Edit operation.", anyOf: unionInputSchema.anyOf } },
|
|
202
|
+
required: ["input"],
|
|
203
|
+
additionalProperties: false,
|
|
204
|
+
definitions: unionInputSchema.definitions,
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("collectMcpTools wraps non-object input and output schemas and flags them", () => {
|
|
209
|
+
const program = testProgram({
|
|
210
|
+
key: "wrapapp",
|
|
211
|
+
description: "Wrap demo.",
|
|
212
|
+
mcpServer: { enabled: true },
|
|
213
|
+
commands: [
|
|
214
|
+
{
|
|
215
|
+
key: "edit",
|
|
216
|
+
description: "Edit.",
|
|
217
|
+
kind: "document",
|
|
218
|
+
inputSchema: unionInputSchema,
|
|
219
|
+
outputSchema: { type: "array", items: { type: "string" } },
|
|
220
|
+
handler: () => [],
|
|
221
|
+
},
|
|
222
|
+
{ key: "plain", description: "Plain.", handler: () => {} },
|
|
223
|
+
],
|
|
224
|
+
});
|
|
225
|
+
const tools = collectMcpTools(program);
|
|
226
|
+
const edit = requireMcpTool(tools, "edit");
|
|
227
|
+
expect(edit.inputWrapped).toBe(true);
|
|
228
|
+
expect(edit.inputSchema.type).toBe("object");
|
|
229
|
+
expect(edit.outputWrapped).toBe(true);
|
|
230
|
+
expect(edit.outputSchema).toEqual({
|
|
231
|
+
type: "object",
|
|
232
|
+
properties: { result: { type: "array", items: { type: "string" } } },
|
|
233
|
+
required: ["result"],
|
|
234
|
+
additionalProperties: false,
|
|
235
|
+
});
|
|
236
|
+
const plain = requireMcpTool(tools, "plain");
|
|
237
|
+
expect(plain.inputWrapped).toBe(false);
|
|
238
|
+
expect(plain.outputWrapped).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("MCP tool schema startup check", () => {
|
|
243
|
+
/** Program with one document leaf using `inputSchema`, MCP on or off. */
|
|
244
|
+
function schemaProgram(inputSchema: Record<string, unknown>, mcpEnabled: boolean) {
|
|
245
|
+
return testProgram({
|
|
246
|
+
key: "checkapp",
|
|
247
|
+
description: "Check demo.",
|
|
248
|
+
...(mcpEnabled ? { mcpServer: { enabled: true } } : {}),
|
|
249
|
+
commands: [{ key: "run", description: "Run.", kind: "document", inputSchema, handler: () => {} }],
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
test("accepts wrapped schemas whose definitions still resolve", () => {
|
|
254
|
+
expect(() => cliValidateProgram(schemaProgram(unionInputSchema, true))).not.toThrow();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("rejects an unresolved local $ref when MCP is enabled", () => {
|
|
258
|
+
const dangling = { $ref: "#/definitions/Missing", definitions: {} };
|
|
259
|
+
expect(() => cliValidateProgram(schemaProgram(dangling, true))).toThrow(
|
|
260
|
+
'MCP tool "run" inputSchema has an unresolved $ref: #/definitions/Missing',
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('rejects $ref "#" in a wrapped schema', () => {
|
|
265
|
+
const recursive = { anyOf: [{ type: "object" }, { type: "array", items: { $ref: "#" } }] };
|
|
266
|
+
expect(() => cliValidateProgram(schemaProgram(recursive, true))).toThrow('uses $ref "#"');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("skips the check when MCP is disabled", () => {
|
|
270
|
+
const dangling = { $ref: "#/definitions/Missing", definitions: {} };
|
|
271
|
+
expect(() => cliValidateProgram(schemaProgram(dangling, false))).not.toThrow();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("skips MCP-hidden leaves", () => {
|
|
275
|
+
const program = testProgram({
|
|
276
|
+
key: "checkapp",
|
|
277
|
+
description: "Check demo.",
|
|
278
|
+
mcpServer: { enabled: true },
|
|
279
|
+
commands: [
|
|
280
|
+
{
|
|
281
|
+
key: "run",
|
|
282
|
+
description: "Run.",
|
|
283
|
+
kind: "document",
|
|
284
|
+
inputSchema: { $ref: "#/definitions/Missing", definitions: {} },
|
|
285
|
+
mcpTool: { hidden: true },
|
|
286
|
+
handler: () => {},
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
});
|
|
290
|
+
expect(() => cliValidateProgram(program)).not.toThrow();
|
|
291
|
+
});
|
|
292
|
+
});
|
package/src/mcp/tools.ts
CHANGED
|
@@ -49,10 +49,51 @@ export interface McpToolDef {
|
|
|
49
49
|
path: string[];
|
|
50
50
|
/** Leaf command node. */
|
|
51
51
|
leaf: CliLeaf;
|
|
52
|
-
/** JSON Schema for tools/call arguments. */
|
|
52
|
+
/** JSON Schema for tools/call arguments (wrapped under {@link MCP_INPUT_WRAPPER_KEY} when not object-rooted). */
|
|
53
53
|
inputSchema: Record<string, unknown>;
|
|
54
|
-
/**
|
|
54
|
+
/** True when {@link inputSchema} wraps the leaf schema; tools/call arguments are unwrapped before invoke. */
|
|
55
|
+
inputWrapped: boolean;
|
|
56
|
+
/** JSON Schema for structured tool results (wrapped under {@link MCP_OUTPUT_WRAPPER_KEY} when not object-rooted). */
|
|
55
57
|
outputSchema?: Record<string, unknown>;
|
|
58
|
+
/** True when {@link outputSchema} wraps the leaf schema; `structuredContent` is wrapped to match. */
|
|
59
|
+
outputWrapped: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Property holding the leaf input when an MCP `inputSchema` is wrapped to get an object root. */
|
|
63
|
+
export const MCP_INPUT_WRAPPER_KEY = "input";
|
|
64
|
+
|
|
65
|
+
/** Property holding the leaf result when an MCP `outputSchema` is wrapped to get an object root. */
|
|
66
|
+
export const MCP_OUTPUT_WRAPPER_KEY = "result";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* MCP requires `type: "object"` at the root of tool input and output schemas. Returns object-rooted
|
|
70
|
+
* schemas unchanged; wraps anything else (e.g. a discriminated-union `anyOf` root) as a single
|
|
71
|
+
* required property, moving `$schema`, `$id`, `definitions`, and `$defs` up so `#/definitions/…`
|
|
72
|
+
* references still resolve.
|
|
73
|
+
*/
|
|
74
|
+
export function wrapMcpRootSchema(
|
|
75
|
+
/** Leaf input or output schema. */
|
|
76
|
+
schema: Record<string, unknown>,
|
|
77
|
+
/** Wrapper property name ({@link MCP_INPUT_WRAPPER_KEY} or {@link MCP_OUTPUT_WRAPPER_KEY}). */
|
|
78
|
+
key: string,
|
|
79
|
+
): { schema: Record<string, unknown>; wrapped: boolean } {
|
|
80
|
+
if (schema.type === "object") {
|
|
81
|
+
return { schema, wrapped: false };
|
|
82
|
+
}
|
|
83
|
+
const { $schema, $id, definitions, $defs, ...inner } = schema;
|
|
84
|
+
return {
|
|
85
|
+
schema: {
|
|
86
|
+
...($schema === undefined ? {} : { $schema }),
|
|
87
|
+
...($id === undefined ? {} : { $id }),
|
|
88
|
+
type: "object",
|
|
89
|
+
properties: { [key]: inner },
|
|
90
|
+
required: [key],
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
...(definitions === undefined ? {} : { definitions }),
|
|
93
|
+
...($defs === undefined ? {} : { $defs }),
|
|
94
|
+
},
|
|
95
|
+
wrapped: true,
|
|
96
|
+
};
|
|
56
97
|
}
|
|
57
98
|
|
|
58
99
|
/** Builds MCP tool description: "{cli path} — {description}". */
|
|
@@ -163,14 +204,18 @@ export function collectMcpTools(root: CliProgram): McpToolDef[] {
|
|
|
163
204
|
if (isMcpHidden(cmd)) {
|
|
164
205
|
return;
|
|
165
206
|
}
|
|
166
|
-
const
|
|
207
|
+
const input = wrapMcpRootSchema(buildLeafInputSchema(cmd), MCP_INPUT_WRAPPER_KEY);
|
|
208
|
+
const leafOutput = leafOutputSchema(cmd);
|
|
209
|
+
const output = leafOutput === undefined ? undefined : wrapMcpRootSchema(leafOutput, MCP_OUTPUT_WRAPPER_KEY);
|
|
167
210
|
out.push({
|
|
168
211
|
name: mcpToolName(root, path),
|
|
169
212
|
description: resolveToolDescription(root, path, cmd),
|
|
170
213
|
path,
|
|
171
214
|
leaf: cmd,
|
|
172
|
-
inputSchema:
|
|
173
|
-
|
|
215
|
+
inputSchema: input.schema,
|
|
216
|
+
inputWrapped: input.wrapped,
|
|
217
|
+
...(output === undefined ? {} : { outputSchema: output.schema }),
|
|
218
|
+
outputWrapped: output?.wrapped ?? false,
|
|
174
219
|
});
|
|
175
220
|
return;
|
|
176
221
|
}
|