argsbarg 7.1.1 → 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.
- package/CHANGELOG.md +38 -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 +53 -4
- 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 +14 -1
- package/examples/mcp-plugin/README.md +19 -10
- package/examples/mcp-plugin/bun.lock +83 -1
- package/examples/mcp-plugin/bunfig.toml +4 -0
- package/examples/mcp-plugin/docs/node-distro.md +97 -0
- package/examples/mcp-plugin/justfile +12 -11
- package/examples/mcp-plugin/package.json +2 -1
- package/examples/mcp-plugin/scripts/release.ts +10 -11
- package/index.d.ts +62 -0
- 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.test.ts +157 -0
- package/src/config/validate.ts +353 -26
- package/src/core/document-leaf.test.ts +53 -0
- package/src/core/json-pointer.ts +46 -0
- package/src/core/types.ts +33 -0
- package/src/core/validate.ts +68 -1
- package/src/docs/docs.test.ts +7 -0
- package/src/docs/mcp-guide.ts +43 -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/index.ts +3 -0
- package/src/mcp/server.ts +28 -4
- package/src/mcp/tools.test.ts +292 -0
- package/src/mcp/tools.ts +144 -6
- package/src/runtime/cli.ts +6 -1
- package/src/server/context.ts +6 -0
- package/src/test/integration/mcp.test.ts +73 -0
- package/src/test/mcp-integration-fixture.ts +1 -0
- package/src/test/mcp-size-fixture.ts +31 -0
- package/examples/mcp-plugin/.mcp.json +0 -6
- 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
package/src/docs/mcp-guide.ts
CHANGED
|
@@ -3,7 +3,15 @@ import { displayAppConfigPath } from "../config/file.ts";
|
|
|
3
3
|
import { expectedMcpEntry } from "../configure/artifacts/mcp-config.ts";
|
|
4
4
|
import { resolveClaudeDesktopMcpPath, userHome } from "../configure/artifacts/paths.ts";
|
|
5
5
|
import { CliOptionKind, type CliProgram } from "../core/types.ts";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
collectMcpTools,
|
|
8
|
+
DEFAULT_MCP_SIZE_LIMITS,
|
|
9
|
+
leafWireOptions,
|
|
10
|
+
type McpToolDef,
|
|
11
|
+
mcpServerId,
|
|
12
|
+
mcpSizeReport,
|
|
13
|
+
resolveMcpSchemaUri,
|
|
14
|
+
} from "../mcp/tools.ts";
|
|
7
15
|
import { resolveCapabilities } from "../runtime/capabilities.ts";
|
|
8
16
|
import { resolveDocsTopicResourceUri } from "./mcp-resources.ts";
|
|
9
17
|
import { docsEnabled, docsUserTopicKeys, resolveDocsConfig } from "./resolve.ts";
|
|
@@ -173,6 +181,9 @@ export function generateMcpGuide(root: CliProgram): string {
|
|
|
173
181
|
"| `tools/call` | Runs handlers headlessly; JSON stdout becomes `structuredContent` when valid |",
|
|
174
182
|
`| Schema resource | \`${schemaUri}\` — same JSON as \`${root.key} docs cli-schema\` |`,
|
|
175
183
|
);
|
|
184
|
+
if (mcp.instructions) {
|
|
185
|
+
lines.push(`| \`initialize.instructions\` | ${mcp.instructions} |`);
|
|
186
|
+
}
|
|
176
187
|
if (docsEnabled(root)) {
|
|
177
188
|
const docs = resolveDocsConfig(root);
|
|
178
189
|
for (const key of docsUserTopicKeys(docs)) {
|
|
@@ -191,6 +202,37 @@ export function generateMcpGuide(root: CliProgram): string {
|
|
|
191
202
|
lines.push("");
|
|
192
203
|
}
|
|
193
204
|
|
|
205
|
+
const sizeReport = mcpSizeReport(root);
|
|
206
|
+
if (sizeReport.tools.length > 0) {
|
|
207
|
+
const limits = { ...DEFAULT_MCP_SIZE_LIMITS, ...root.mcpServer?.sizeLimits };
|
|
208
|
+
lines.push(
|
|
209
|
+
"## Tool sizes",
|
|
210
|
+
"",
|
|
211
|
+
`Clients read tool definitions with their own limits — a definition or description past those is truncated ` +
|
|
212
|
+
`or read incompletely. Default limits here: description ${limits.descriptionChars === false ? "unchecked" : `${limits.descriptionChars.toLocaleString()} chars`}, ` +
|
|
213
|
+
`definition ${limits.definitionBytes === false ? "unchecked" : `${limits.definitionBytes.toLocaleString()} bytes`} / ` +
|
|
214
|
+
`${limits.definitionLines === false ? "unchecked" : `${limits.definitionLines.toLocaleString()} lines`} (override with \`mcpServer.sizeLimits\`).`,
|
|
215
|
+
"",
|
|
216
|
+
"| Tool | Description (chars) | Definition (bytes) | Definition (lines) | Status |",
|
|
217
|
+
"| --- | --- | --- | --- | --- |",
|
|
218
|
+
);
|
|
219
|
+
for (const t of sizeReport.tools) {
|
|
220
|
+
const over: string[] = [];
|
|
221
|
+
if (limits.descriptionChars !== false && t.descriptionChars > limits.descriptionChars) over.push("description");
|
|
222
|
+
if (
|
|
223
|
+
(limits.definitionBytes !== false && t.definitionBytes > limits.definitionBytes) ||
|
|
224
|
+
(limits.definitionLines !== false && t.definitionLines > limits.definitionLines)
|
|
225
|
+
) {
|
|
226
|
+
over.push("definition");
|
|
227
|
+
}
|
|
228
|
+
const status = over.length === 0 ? "ok" : `over: ${over.join(", ")}`;
|
|
229
|
+
lines.push(
|
|
230
|
+
`| \`${t.name}\` | ${t.descriptionChars.toLocaleString()} | ${t.definitionBytes.toLocaleString()} | ${t.definitionLines.toLocaleString()} | ${status} |`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
lines.push("");
|
|
234
|
+
}
|
|
235
|
+
|
|
194
236
|
lines.push(
|
|
195
237
|
"## Tool arguments",
|
|
196
238
|
"",
|
|
@@ -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/index.ts
CHANGED
|
@@ -44,6 +44,7 @@ export type {
|
|
|
44
44
|
CliMcpBundleConfig,
|
|
45
45
|
CliMcpResource,
|
|
46
46
|
CliMcpServerConfig,
|
|
47
|
+
CliMcpSizeLimits,
|
|
47
48
|
CliMcpToolConfig,
|
|
48
49
|
CliMcpWireContext,
|
|
49
50
|
CliMcpWireHooks,
|
|
@@ -87,6 +88,8 @@ export type { EcsLogEvent, LogEnrichContext } from "./log/ecs.ts";
|
|
|
87
88
|
export { ECS_VERSION, formatEcsLine } from "./log/ecs.ts";
|
|
88
89
|
export type { McpBundlePaths, PackMcpBundleOpts } from "./mcp/bundle.ts";
|
|
89
90
|
export { defaultMcpBundlePaths, generateMcpManifest, packMcpBundle } from "./mcp/bundle.ts";
|
|
91
|
+
export type { McpSizeReport, McpToolSize } from "./mcp/tools.ts";
|
|
92
|
+
export { DEFAULT_MCP_SIZE_LIMITS, mcpSizeReport } from "./mcp/tools.ts";
|
|
90
93
|
export { userHome } from "./paths/host.ts";
|
|
91
94
|
export { Cli, type CliInvokeKind, type CliInvokeResult } from "./runtime/cli.ts";
|
|
92
95
|
export { cliErrWithHelp } from "./runtime/cli-errors.ts";
|
package/src/mcp/server.ts
CHANGED
|
@@ -8,7 +8,11 @@ import { executeHeadlessToolCall, headlessFailureMcpMessage, lookupHeadlessTool
|
|
|
8
8
|
import type { Cli } from "../runtime/cli.ts";
|
|
9
9
|
import { allMcpResources, collectMcpTools, resolveMcpServerInfo } from "./tools.ts";
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
/** Protocol versions this server understands, newest first. `initialize` echoes a match or answers the first. */
|
|
12
|
+
export const MCP_PROTOCOL_VERSIONS = ["2025-06-18", "2024-11-05"] as const;
|
|
13
|
+
|
|
14
|
+
/** The first protocol version to define `outputSchema` (tools/list) and `structuredContent` (tools/call). */
|
|
15
|
+
const MCP_STRUCTURED_OUTPUT_SINCE = "2025-06-18";
|
|
12
16
|
|
|
13
17
|
/** JSON-RPC request shape from stdin. */
|
|
14
18
|
interface JsonRpcRequest {
|
|
@@ -35,6 +39,13 @@ function writeError(id: string | number | null | undefined, code: number, messag
|
|
|
35
39
|
});
|
|
36
40
|
}
|
|
37
41
|
|
|
42
|
+
/** True when `version` is at or after {@link MCP_STRUCTURED_OUTPUT_SINCE} in {@link MCP_PROTOCOL_VERSIONS}. */
|
|
43
|
+
function supportsStructuredOutput(version: string | undefined): boolean {
|
|
44
|
+
const idx = version ? (MCP_PROTOCOL_VERSIONS as readonly string[]).indexOf(version) : -1;
|
|
45
|
+
const sinceIdx = (MCP_PROTOCOL_VERSIONS as readonly string[]).indexOf(MCP_STRUCTURED_OUTPUT_SINCE);
|
|
46
|
+
return idx !== -1 && idx <= sinceIdx;
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
/** Handles one NDJSON request line. */
|
|
39
50
|
async function handleRequestLine(cli: Cli, line: string): Promise<void> {
|
|
40
51
|
const root = cli.program;
|
|
@@ -98,13 +109,23 @@ async function handleRequestLine(cli: Cli, line: string): Promise<void> {
|
|
|
98
109
|
try {
|
|
99
110
|
if (method === "initialize") {
|
|
100
111
|
const info = resolveMcpServerInfo(root);
|
|
112
|
+
const requested = params.protocolVersion;
|
|
113
|
+
const negotiated =
|
|
114
|
+
typeof requested === "string" && (MCP_PROTOCOL_VERSIONS as readonly string[]).includes(requested)
|
|
115
|
+
? requested
|
|
116
|
+
: MCP_PROTOCOL_VERSIONS[0];
|
|
117
|
+
if (cli.server) {
|
|
118
|
+
cli.server.mcpProtocolVersion = negotiated;
|
|
119
|
+
}
|
|
120
|
+
const instructions = root.mcpServer?.instructions;
|
|
101
121
|
writeResponse({
|
|
102
122
|
jsonrpc: "2.0",
|
|
103
123
|
id,
|
|
104
124
|
result: {
|
|
105
|
-
protocolVersion:
|
|
125
|
+
protocolVersion: negotiated,
|
|
106
126
|
capabilities: { tools: {}, resources: {} },
|
|
107
127
|
serverInfo: { name: info.name, version: info.version },
|
|
128
|
+
...(instructions ? { instructions } : {}),
|
|
108
129
|
},
|
|
109
130
|
});
|
|
110
131
|
await finish();
|
|
@@ -118,11 +139,12 @@ async function handleRequestLine(cli: Cli, line: string): Promise<void> {
|
|
|
118
139
|
}
|
|
119
140
|
|
|
120
141
|
if (method === "tools/list") {
|
|
142
|
+
const structured = supportsStructuredOutput(cli.server?.mcpProtocolVersion);
|
|
121
143
|
const tools = collectMcpTools(root).map((t) => ({
|
|
122
144
|
name: t.name,
|
|
123
145
|
description: t.description,
|
|
124
146
|
inputSchema: t.inputSchema,
|
|
125
|
-
...(t.outputSchema
|
|
147
|
+
...(structured && t.outputSchema !== undefined ? { outputSchema: t.outputSchema } : {}),
|
|
126
148
|
}));
|
|
127
149
|
writeResponse({ jsonrpc: "2.0", id, result: { tools } });
|
|
128
150
|
await finish();
|
|
@@ -168,10 +190,12 @@ async function handleRequestLine(cli: Cli, line: string): Promise<void> {
|
|
|
168
190
|
{ rpcMethod: method, toolName: name, requestId },
|
|
169
191
|
);
|
|
170
192
|
if (invokeResult.ok) {
|
|
193
|
+
const structured = supportsStructuredOutput(cli.server?.mcpProtocolVersion);
|
|
194
|
+
const { structuredContent: _structuredContent, ...rest } = invokeResult.mcpResult;
|
|
171
195
|
writeResponse({
|
|
172
196
|
jsonrpc: "2.0",
|
|
173
197
|
id,
|
|
174
|
-
result: invokeResult.mcpResult,
|
|
198
|
+
result: structured ? invokeResult.mcpResult : rest,
|
|
175
199
|
});
|
|
176
200
|
await finish();
|
|
177
201
|
return;
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/*
|
|
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.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { cliPresentationRoot } from "../builtins/presentation.ts";
|
|
8
|
+
import { CliOptionKind } from "../core/types.ts";
|
|
9
|
+
import { cliValidateProgram } from "../core/validate.ts";
|
|
10
|
+
import { cliHelpRender } from "../help.ts";
|
|
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";
|
|
19
|
+
|
|
20
|
+
describe("mcpSizeReport", () => {
|
|
21
|
+
test("measures description and pretty definition against a hand-built JSON.stringify", () => {
|
|
22
|
+
const program = testProgram({
|
|
23
|
+
key: "sizetest",
|
|
24
|
+
description: "size test",
|
|
25
|
+
mcpServer: { enabled: true },
|
|
26
|
+
commands: [{ key: "run", description: "Run it.", handler: () => {} }],
|
|
27
|
+
});
|
|
28
|
+
const report = mcpSizeReport(program);
|
|
29
|
+
const [tool] = collectMcpTools(program);
|
|
30
|
+
expect(tool).toBeDefined();
|
|
31
|
+
|
|
32
|
+
const expectedJson = JSON.stringify(
|
|
33
|
+
{ name: tool?.name, description: tool?.description, inputSchema: tool?.inputSchema },
|
|
34
|
+
null,
|
|
35
|
+
2,
|
|
36
|
+
);
|
|
37
|
+
expect(report.tools).toHaveLength(1);
|
|
38
|
+
expect(report.tools[0]).toEqual({
|
|
39
|
+
name: tool?.name,
|
|
40
|
+
descriptionChars: tool?.description.length,
|
|
41
|
+
definitionBytes: Buffer.byteLength(expectedJson, "utf8"),
|
|
42
|
+
definitionLines: expectedJson.split("\n").length,
|
|
43
|
+
});
|
|
44
|
+
expect(report.warnings).toEqual([]);
|
|
45
|
+
expect(report.instructionsChars).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("warns past default limits for description and definition size", () => {
|
|
49
|
+
const program = testProgram({
|
|
50
|
+
key: "sizetest2",
|
|
51
|
+
description: "size test 2",
|
|
52
|
+
mcpServer: { enabled: true },
|
|
53
|
+
commands: [
|
|
54
|
+
{
|
|
55
|
+
key: "small",
|
|
56
|
+
description: "Small tool.",
|
|
57
|
+
notes: "x".repeat(3_000),
|
|
58
|
+
handler: () => {},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: "big",
|
|
62
|
+
description: "Big tool.",
|
|
63
|
+
options: [
|
|
64
|
+
{
|
|
65
|
+
name: "mode",
|
|
66
|
+
description: "Mode.",
|
|
67
|
+
kind: CliOptionKind.Enum,
|
|
68
|
+
choices: Array.from({ length: 4_000 }, (_, i) => `choice-${i}`),
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
handler: () => {},
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
const report = mcpSizeReport(program);
|
|
76
|
+
|
|
77
|
+
const smallWarning = report.warnings.find((w) => w.includes('"small"'));
|
|
78
|
+
expect(smallWarning).toBeDefined();
|
|
79
|
+
expect(smallWarning).toContain("description is");
|
|
80
|
+
expect(smallWarning).toContain(`limit ${DEFAULT_MCP_SIZE_LIMITS.descriptionChars.toLocaleString()}`);
|
|
81
|
+
|
|
82
|
+
const bigWarning = report.warnings.find((w) => w.includes('"big"'));
|
|
83
|
+
expect(bigWarning).toBeDefined();
|
|
84
|
+
expect(bigWarning).toContain("definition is");
|
|
85
|
+
expect(bigWarning).toContain("pretty-printed");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("sizeLimits overrides raise or lower the threshold", () => {
|
|
89
|
+
const program = testProgram({
|
|
90
|
+
key: "sizetest3",
|
|
91
|
+
description: "size test 3",
|
|
92
|
+
mcpServer: { enabled: true, sizeLimits: { descriptionChars: 5 } },
|
|
93
|
+
commands: [
|
|
94
|
+
{ key: "run", description: "Run it, with a description longer than five characters.", handler: () => {} },
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
const report = mcpSizeReport(program);
|
|
98
|
+
expect(report.warnings.some((w) => w.includes("description is"))).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("sizeLimits: false disables a check entirely", () => {
|
|
102
|
+
const program = testProgram({
|
|
103
|
+
key: "sizetest4",
|
|
104
|
+
description: "size test 4",
|
|
105
|
+
mcpServer: { enabled: true, sizeLimits: { descriptionChars: false } },
|
|
106
|
+
commands: [
|
|
107
|
+
{ key: "run", description: "Run it, with a description longer than five characters.", handler: () => {} },
|
|
108
|
+
],
|
|
109
|
+
});
|
|
110
|
+
const report = mcpSizeReport(program);
|
|
111
|
+
expect(report.warnings.some((w) => w.includes("description is"))).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("warns when instructions exceed the limit", () => {
|
|
115
|
+
const program = testProgram({
|
|
116
|
+
key: "sizetest5",
|
|
117
|
+
description: "size test 5",
|
|
118
|
+
mcpServer: { enabled: true, instructions: "x".repeat(3_000) },
|
|
119
|
+
commands: [{ key: "run", description: "Run it.", handler: () => {} }],
|
|
120
|
+
});
|
|
121
|
+
const report = mcpSizeReport(program);
|
|
122
|
+
expect(report.instructionsChars).toBe(3_000);
|
|
123
|
+
expect(report.warnings.some((w) => w.startsWith("MCP instructions are"))).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("mcpTool.notes", () => {
|
|
128
|
+
function programWithNotesOverride(notesOverride: string | false | undefined) {
|
|
129
|
+
return testProgram({
|
|
130
|
+
key: "notestest",
|
|
131
|
+
description: "notes test",
|
|
132
|
+
mcpServer: { enabled: true },
|
|
133
|
+
commands: [
|
|
134
|
+
{
|
|
135
|
+
key: "run",
|
|
136
|
+
description: "Run it.",
|
|
137
|
+
notes: "Original CLI notes.",
|
|
138
|
+
...(notesOverride === undefined ? {} : { mcpTool: { notes: notesOverride } }),
|
|
139
|
+
handler: () => {},
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
test("false omits notes from the MCP description", () => {
|
|
146
|
+
const [tool] = collectMcpTools(programWithNotesOverride(false));
|
|
147
|
+
expect(tool?.description).not.toContain("Original CLI notes.");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("a string replaces the leaf's notes in the MCP description", () => {
|
|
151
|
+
const [tool] = collectMcpTools(programWithNotesOverride("Custom MCP-only note."));
|
|
152
|
+
expect(tool?.description).toContain("Custom MCP-only note.");
|
|
153
|
+
expect(tool?.description).not.toContain("Original CLI notes.");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("omitted falls through to the leaf's own notes", () => {
|
|
157
|
+
const [tool] = collectMcpTools(programWithNotesOverride(undefined));
|
|
158
|
+
expect(tool?.description).toContain("Original CLI notes.");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("CLI help always shows the leaf's own notes regardless of mcpTool.notes", () => {
|
|
162
|
+
const program = programWithNotesOverride(false);
|
|
163
|
+
const help = cliHelpRender(cliPresentationRoot(program), ["run"], false);
|
|
164
|
+
expect(help).toContain("Original CLI notes.");
|
|
165
|
+
});
|
|
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
|
+
});
|