argsbarg 3.6.4 → 4.0.0
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 +28 -2
- package/README.md +21 -9
- package/docs/README.md +12 -8
- package/docs/bundled-docs.md +1 -1
- package/docs/cli-program.md +62 -2
- package/docs/config-schema.md +192 -0
- package/docs/developing.md +13 -0
- package/docs/install.md +38 -1
- package/docs/mcp.md +43 -19
- package/docs/output-schema.md +74 -52
- package/docs/templates/cursor/rules/cli-program.mdc +10 -5
- package/examples/config-app/main.ts +20 -0
- package/examples/config-app/program.ts +81 -0
- package/examples/config-app/schema.ts +37 -0
- package/examples/config-app/types.ts +19 -0
- package/examples/consumer-app/README.md +56 -0
- package/examples/consumer-app/bun.lock +75 -0
- package/examples/consumer-app/capabilities.test.ts +69 -0
- package/examples/consumer-app/package.json +17 -0
- package/examples/consumer-app/schemas/configSchemas.ts +6 -0
- package/examples/consumer-app/schemas/generated/app-config.json +40 -0
- package/examples/consumer-app/schemas/generated/status.json +28 -0
- package/examples/consumer-app/schemas/outputSchemas.ts +6 -0
- package/examples/consumer-app/scripts/schemagen/discover-schema-roots.test.ts +25 -0
- package/examples/consumer-app/scripts/schemagen/discover-schema-roots.ts +93 -0
- package/examples/consumer-app/scripts/schemagen/naming.ts +82 -0
- package/examples/consumer-app/scripts/schemagen.ts +76 -0
- package/examples/consumer-app/src/commands/status/types.ts +11 -0
- package/examples/consumer-app/src/main.ts +15 -0
- package/examples/consumer-app/src/program.ts +116 -0
- package/examples/consumer-app/src/types.ts +23 -0
- package/examples/consumer-app/tsconfig.json +14 -0
- package/examples/formats.ts +10 -3
- package/examples/mcp-test.ts +27 -8
- package/examples/minimal.ts +4 -3
- package/examples/nested.ts +5 -4
- package/examples/option-required.ts +8 -4
- package/index.d.ts +152 -75
- package/package.json +1 -1
- package/src/builtins/builtins.test.ts +13 -0
- package/src/builtins/config.test.ts +82 -0
- package/src/builtins/config.ts +220 -0
- package/src/builtins/dispatch.ts +18 -9
- package/src/builtins/export.ts +8 -33
- package/src/builtins/index.ts +1 -0
- package/src/builtins/install.ts +13 -0
- package/src/builtins/mcp.ts +1 -1
- package/src/builtins/presentation.ts +2 -17
- package/src/builtins/registry.ts +40 -0
- package/src/capabilities.ts +46 -0
- package/src/cli-errors.ts +15 -0
- package/src/cli.ts +389 -0
- package/src/config/bootstrap.ts +265 -0
- package/src/config/context.test.ts +61 -0
- package/src/config/context.ts +98 -0
- package/src/config/entry.ts +81 -0
- package/src/config/file.test.ts +112 -0
- package/src/config/file.ts +120 -0
- package/src/config/manifest.ts +62 -0
- package/src/config/resolve.test.ts +88 -0
- package/src/config/resolve.ts +167 -0
- package/src/config/schema.ts +101 -0
- package/src/config/validate.test.ts +63 -0
- package/src/config/validate.ts +292 -0
- package/src/config.integration.test.ts +100 -0
- package/src/context.ts +5 -0
- package/src/docs/docs.test.ts +16 -16
- package/src/docs/mcp-guide.ts +35 -11
- package/src/hidden-mcpb.test.ts +40 -2
- package/src/index.ts +4 -3
- package/src/install/index.ts +46 -3
- package/src/install/paths.ts +5 -20
- package/src/install/plan.ts +6 -0
- package/src/install/status.ts +12 -0
- package/src/install/uninstall.ts +11 -0
- package/src/install/update.test.ts +5 -5
- package/src/invoke.test.ts +207 -0
- package/src/mcp/bundle.ts +9 -116
- package/src/mcp/claude.test.ts +73 -0
- package/src/mcp/claude.ts +168 -0
- package/src/mcp/env.ts +3 -37
- package/src/mcp/server.ts +18 -10
- package/src/mcp/tools.ts +3 -7
- package/src/mcp/zip.ts +82 -0
- package/src/mcp.integration.test.ts +502 -0
- package/src/{index.test.ts → parse.test.ts} +24 -935
- package/src/paths/host.ts +40 -0
- package/src/schema.ts +1 -1
- package/src/skill/generate.ts +18 -5
- package/src/skill/hint.ts +18 -0
- package/src/skill/install.ts +1 -5
- package/src/test-fixtures.ts +192 -0
- package/src/types.ts +39 -13
- package/src/validate.ts +70 -0
- package/src/completion.ts +0 -13
- package/src/invoke.ts +0 -217
- package/src/mcp.ts +0 -28
- package/src/runtime.ts +0 -134
package/src/mcp/server.ts
CHANGED
|
@@ -3,8 +3,9 @@ This module implements the MCP JSON-RPC server over stdio: initialize, tools,
|
|
|
3
3
|
resources, and ping. Responses are newline-delimited JSON on stdout only.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
6
|
+
import type { Cli } from "../cli.ts";
|
|
7
|
+
import { bootstrapAppConfig } from "../config/bootstrap.ts";
|
|
8
|
+
import { formatMcpMissingConfigMessage, missingRequiredConfig } from "../config/resolve.ts";
|
|
8
9
|
import { buildToolCallSuccess } from "./result.ts";
|
|
9
10
|
import {
|
|
10
11
|
allMcpResources,
|
|
@@ -41,7 +42,8 @@ function writeError(id: string | number | null | undefined, code: number, messag
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
/** Handles one NDJSON request line. */
|
|
44
|
-
async function handleRequestLine(
|
|
45
|
+
async function handleRequestLine(cli: Cli, line: string): Promise<void> {
|
|
46
|
+
const root = cli.program;
|
|
45
47
|
let req: JsonRpcRequest;
|
|
46
48
|
try {
|
|
47
49
|
req = JSON.parse(line) as JsonRpcRequest;
|
|
@@ -121,13 +123,19 @@ async function handleRequestLine(root: CliProgram, line: string): Promise<void>
|
|
|
121
123
|
writeError(id, -32602, `Unknown tool: ${name}`);
|
|
122
124
|
return;
|
|
123
125
|
}
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
+
const { resolved } = bootstrapAppConfig(root, { validateFile: false });
|
|
127
|
+
const missingConfig = missingRequiredConfig(root, resolved);
|
|
128
|
+
if (missingConfig.length > 0) {
|
|
126
129
|
writeResponse({
|
|
127
130
|
jsonrpc: "2.0",
|
|
128
131
|
id,
|
|
129
132
|
result: {
|
|
130
|
-
content: [
|
|
133
|
+
content: [
|
|
134
|
+
{
|
|
135
|
+
type: "text",
|
|
136
|
+
text: formatMcpMissingConfigMessage(root, missingConfig),
|
|
137
|
+
},
|
|
138
|
+
],
|
|
131
139
|
isError: true,
|
|
132
140
|
},
|
|
133
141
|
});
|
|
@@ -145,7 +153,7 @@ async function handleRequestLine(root: CliProgram, line: string): Promise<void>
|
|
|
145
153
|
});
|
|
146
154
|
return;
|
|
147
155
|
}
|
|
148
|
-
const invokeResult = await
|
|
156
|
+
const invokeResult = await cli.invoke(argvResult);
|
|
149
157
|
if (invokeResult.kind === "ok" && invokeResult.exitCode === 0) {
|
|
150
158
|
writeResponse({
|
|
151
159
|
jsonrpc: "2.0",
|
|
@@ -222,7 +230,7 @@ async function handleRequestLine(root: CliProgram, line: string): Promise<void>
|
|
|
222
230
|
}
|
|
223
231
|
|
|
224
232
|
/** Runs the MCP NDJSON read loop on stdin until EOF. */
|
|
225
|
-
export async function mcpServeStdioLoop(
|
|
233
|
+
export async function mcpServeStdioLoop(cli: Cli): Promise<void> {
|
|
226
234
|
let buffer = "";
|
|
227
235
|
for await (const chunk of Bun.stdin.stream()) {
|
|
228
236
|
buffer += new TextDecoder().decode(chunk);
|
|
@@ -234,12 +242,12 @@ export async function mcpServeStdioLoop(root: CliProgram): Promise<void> {
|
|
|
234
242
|
nl = buffer.indexOf("\n");
|
|
235
243
|
continue;
|
|
236
244
|
}
|
|
237
|
-
await handleRequestLine(
|
|
245
|
+
await handleRequestLine(cli, line);
|
|
238
246
|
nl = buffer.indexOf("\n");
|
|
239
247
|
}
|
|
240
248
|
}
|
|
241
249
|
const trailing = buffer.trim();
|
|
242
250
|
if (trailing.length > 0) {
|
|
243
|
-
await handleRequestLine(
|
|
251
|
+
await handleRequestLine(cli, trailing);
|
|
244
252
|
}
|
|
245
253
|
}
|
package/src/mcp/tools.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*
|
|
2
2
|
This module maps CliProgram leaf nodes to MCP tool definitions and converts
|
|
3
|
-
flat JSON tool arguments into argv for
|
|
3
|
+
flat JSON tool arguments into argv for Cli.invoke.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { cliResolveNotes } from "../help.ts";
|
|
@@ -168,17 +168,13 @@ function buildInputSchema(
|
|
|
168
168
|
return schema;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
/** Resolves MCP tool description with optional override
|
|
171
|
+
/** Resolves MCP tool description with optional override and leaf notes. */
|
|
172
172
|
function resolveToolDescription(root: CliProgram, path: string[], leaf: CliLeaf): string {
|
|
173
173
|
let desc: string;
|
|
174
174
|
if (leaf.mcpTool?.description) {
|
|
175
175
|
desc = leaf.mcpTool.description;
|
|
176
176
|
} else {
|
|
177
177
|
desc = mcpToolDescription(path, root.key, leaf.description);
|
|
178
|
-
const env = leaf.mcpTool?.requiresEnv;
|
|
179
|
-
if (env && env.length > 0) {
|
|
180
|
-
desc += ` [requires env: ${env.join(", ")}]`;
|
|
181
|
-
}
|
|
182
178
|
}
|
|
183
179
|
const notes = (leaf.notes ?? "").trim();
|
|
184
180
|
if (notes.length > 0) {
|
|
@@ -277,7 +273,7 @@ export function resolveMcpSchemaUri(root: CliProgram): string {
|
|
|
277
273
|
return defaultMcpSchemaUri(mcpServerId(root));
|
|
278
274
|
}
|
|
279
275
|
|
|
280
|
-
/** Converts flat MCP tool arguments to argv for
|
|
276
|
+
/** Converts flat MCP tool arguments to argv for Cli.invoke. */
|
|
281
277
|
export function mcpToolCallToArgv(
|
|
282
278
|
root: CliProgram,
|
|
283
279
|
tool: McpToolDef,
|
package/src/mcp/zip.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Minimal ZIP writer (store method, no compression) for MCPB and Claude plugin bundles.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** CRC-32 for ZIP local headers. */
|
|
6
|
+
function crc32(data: Buffer): number {
|
|
7
|
+
let crc = 0xffffffff;
|
|
8
|
+
for (let i = 0; i < data.length; i++) {
|
|
9
|
+
const byte = data[i];
|
|
10
|
+
if (byte === undefined) {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
crc ^= byte;
|
|
14
|
+
for (let j = 0; j < 8; j++) {
|
|
15
|
+
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Writes a minimal ZIP (store, no compression) with one or more files. */
|
|
22
|
+
export function zipStore(files: { name: string; data: Buffer }[]): Buffer {
|
|
23
|
+
const parts: Buffer[] = [];
|
|
24
|
+
const central: Buffer[] = [];
|
|
25
|
+
let offset = 0;
|
|
26
|
+
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
const nameBuf = Buffer.from(file.name, "utf8");
|
|
29
|
+
const crc = crc32(file.data);
|
|
30
|
+
const local = Buffer.alloc(30 + nameBuf.length);
|
|
31
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
32
|
+
local.writeUInt16LE(20, 4);
|
|
33
|
+
local.writeUInt16LE(0, 6);
|
|
34
|
+
local.writeUInt16LE(0, 8);
|
|
35
|
+
local.writeUInt16LE(0, 10);
|
|
36
|
+
local.writeUInt16LE(0, 12);
|
|
37
|
+
local.writeUInt32LE(crc, 14);
|
|
38
|
+
local.writeUInt32LE(file.data.length, 18);
|
|
39
|
+
local.writeUInt32LE(file.data.length, 22);
|
|
40
|
+
local.writeUInt32LE(nameBuf.length, 26);
|
|
41
|
+
local.writeUInt16LE(0, 28);
|
|
42
|
+
nameBuf.copy(local, 30);
|
|
43
|
+
|
|
44
|
+
const centralHdr = Buffer.alloc(46 + nameBuf.length);
|
|
45
|
+
centralHdr.writeUInt32LE(0x02014b50, 0);
|
|
46
|
+
centralHdr.writeUInt16LE(20, 4);
|
|
47
|
+
centralHdr.writeUInt16LE(20, 6);
|
|
48
|
+
centralHdr.writeUInt16LE(0, 8);
|
|
49
|
+
centralHdr.writeUInt16LE(0, 10);
|
|
50
|
+
centralHdr.writeUInt16LE(0, 12);
|
|
51
|
+
centralHdr.writeUInt16LE(0, 14);
|
|
52
|
+
centralHdr.writeUInt32LE(crc, 16);
|
|
53
|
+
centralHdr.writeUInt32LE(file.data.length, 20);
|
|
54
|
+
centralHdr.writeUInt32LE(file.data.length, 24);
|
|
55
|
+
centralHdr.writeUInt32LE(nameBuf.length, 28);
|
|
56
|
+
centralHdr.writeUInt16LE(0, 30);
|
|
57
|
+
centralHdr.writeUInt16LE(0, 32);
|
|
58
|
+
centralHdr.writeUInt16LE(0, 34);
|
|
59
|
+
centralHdr.writeUInt16LE(0, 36);
|
|
60
|
+
centralHdr.writeUInt32LE(0, 38);
|
|
61
|
+
centralHdr.writeUInt32LE(offset, 42);
|
|
62
|
+
nameBuf.copy(centralHdr, 46);
|
|
63
|
+
|
|
64
|
+
parts.push(local, file.data);
|
|
65
|
+
central.push(centralHdr);
|
|
66
|
+
offset += local.length + file.data.length;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const centralStart = offset;
|
|
70
|
+
const centralBuf = Buffer.concat(central);
|
|
71
|
+
const end = Buffer.alloc(22);
|
|
72
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
73
|
+
end.writeUInt16LE(0, 4);
|
|
74
|
+
end.writeUInt16LE(0, 6);
|
|
75
|
+
end.writeUInt16LE(files.length, 8);
|
|
76
|
+
end.writeUInt16LE(files.length, 10);
|
|
77
|
+
end.writeUInt32LE(centralBuf.length, 12);
|
|
78
|
+
end.writeUInt32LE(centralStart, 16);
|
|
79
|
+
end.writeUInt16LE(0, 20);
|
|
80
|
+
|
|
81
|
+
return Buffer.concat([...parts, centralBuf, end]);
|
|
82
|
+
}
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Domain-specific regression tests (split from index.test.ts).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { expect, test } from "bun:test";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { $ } from "bun";
|
|
8
|
+
import type { CliProgram } from "./index.ts";
|
|
9
|
+
import { buildToolCallSuccess } from "./mcp/result.ts";
|
|
10
|
+
import {
|
|
11
|
+
collectMcpTools,
|
|
12
|
+
mcpToolCallToArgv,
|
|
13
|
+
mcpToolDescription,
|
|
14
|
+
sanitizeToolSegment,
|
|
15
|
+
} from "./mcp/tools.ts";
|
|
16
|
+
import { cliSchemaExport } from "./schema.ts";
|
|
17
|
+
import { mcpRequest, nestedMcpFixture, testProgram } from "./test-fixtures.ts";
|
|
18
|
+
import { cliValidateProgram } from "./validate.ts";
|
|
19
|
+
|
|
20
|
+
test("sanitizeToolSegment normalizes dotted app keys", () => {
|
|
21
|
+
expect(sanitizeToolSegment("minimal.ts")).toBe("minimal_ts");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("mcpToolDescription formats CLI path and root-leaf prefix", () => {
|
|
25
|
+
expect(mcpToolDescription(["stat", "owner", "lookup"], "nested.ts", "Resolve owner info.")).toBe(
|
|
26
|
+
"stat owner lookup — Resolve owner info.",
|
|
27
|
+
);
|
|
28
|
+
expect(mcpToolDescription(["read"], "nested.ts", "Print files.")).toBe("read — Print files.");
|
|
29
|
+
expect(mcpToolDescription([], "helloapp", "Tiny demo.")).toBe("helloapp — Tiny demo.");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("collectMcpTools lists user leaf commands only", () => {
|
|
33
|
+
const tools = collectMcpTools(nestedMcpFixture);
|
|
34
|
+
const names = tools.map((t) => t.name);
|
|
35
|
+
expect(names).toContain("stat_owner_lookup");
|
|
36
|
+
expect(names).toContain("read");
|
|
37
|
+
expect(names).not.toContain("hidden");
|
|
38
|
+
expect(names).not.toContain("install");
|
|
39
|
+
expect(names).not.toContain("mcp");
|
|
40
|
+
expect(names).not.toContain("completion");
|
|
41
|
+
const lookup = tools.find((t) => t.name === "stat_owner_lookup")!;
|
|
42
|
+
expect(lookup.description).toBe("stat owner lookup — Resolve owner info.");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("collectMcpTools appends leaf notes to MCP tool description", () => {
|
|
46
|
+
const root = testProgram({
|
|
47
|
+
key: "app",
|
|
48
|
+
version: "1.0.0",
|
|
49
|
+
description: "Notes demo.",
|
|
50
|
+
mcpServer: { enabled: true },
|
|
51
|
+
commands: [
|
|
52
|
+
{
|
|
53
|
+
key: "run",
|
|
54
|
+
description: "Run.",
|
|
55
|
+
notes: "Use `--json` for structured output.",
|
|
56
|
+
handler: () => {},
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
const tools = collectMcpTools(root);
|
|
61
|
+
expect(tools[0]?.description).toBe("run — Run.\n\nUse `--json` for structured output.");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("collectMcpTools appends notes after mcpTool.description override", () => {
|
|
65
|
+
const root = testProgram({
|
|
66
|
+
key: "app",
|
|
67
|
+
version: "1.0.0",
|
|
68
|
+
description: "Notes demo.",
|
|
69
|
+
mcpServer: { enabled: true },
|
|
70
|
+
commands: [
|
|
71
|
+
{
|
|
72
|
+
key: "run",
|
|
73
|
+
description: "Run.",
|
|
74
|
+
notes: "Operational hint.",
|
|
75
|
+
mcpTool: { description: "Custom MCP text." },
|
|
76
|
+
handler: () => {},
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
const tools = collectMcpTools(root);
|
|
81
|
+
expect(tools[0]?.description).toBe("Custom MCP text.\n\nOperational hint.");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("collectMcpTools resolves {argsbarg:program} in appended notes", () => {
|
|
85
|
+
const root = testProgram({
|
|
86
|
+
key: "myapp",
|
|
87
|
+
version: "1.0.0",
|
|
88
|
+
description: "Notes demo.",
|
|
89
|
+
mcpServer: { enabled: true },
|
|
90
|
+
commands: [
|
|
91
|
+
{
|
|
92
|
+
key: "run",
|
|
93
|
+
description: "Run.",
|
|
94
|
+
notes: "See `{argsbarg:program} docs api`.",
|
|
95
|
+
handler: () => {},
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
const tools = collectMcpTools(root);
|
|
100
|
+
expect(tools[0]?.description).toContain("See `myapp docs api`.");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("cliSchemaExport includes leaf outputSchema", () => {
|
|
104
|
+
const root = testProgram({
|
|
105
|
+
key: "app",
|
|
106
|
+
version: "1.0.0",
|
|
107
|
+
description: "Schema export demo.",
|
|
108
|
+
mcpServer: { enabled: true },
|
|
109
|
+
commands: [
|
|
110
|
+
{
|
|
111
|
+
key: "run",
|
|
112
|
+
description: "Run.",
|
|
113
|
+
outputSchema: {
|
|
114
|
+
type: "object",
|
|
115
|
+
properties: { ok: { type: "boolean" } },
|
|
116
|
+
},
|
|
117
|
+
handler: () => {},
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
});
|
|
121
|
+
const schema = cliSchemaExport(root);
|
|
122
|
+
expect(schema.commands?.[0]?.outputSchema).toEqual({
|
|
123
|
+
type: "object",
|
|
124
|
+
properties: { ok: { type: "boolean" } },
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("cliSchemaExport accepts legacy mcpTool.outputSchema", () => {
|
|
129
|
+
const root = testProgram({
|
|
130
|
+
key: "app",
|
|
131
|
+
version: "1.0.0",
|
|
132
|
+
description: "Schema export demo.",
|
|
133
|
+
commands: [
|
|
134
|
+
{
|
|
135
|
+
key: "run",
|
|
136
|
+
description: "Run.",
|
|
137
|
+
mcpTool: {
|
|
138
|
+
outputSchema: { type: "object", properties: { id: { type: "string" } } },
|
|
139
|
+
},
|
|
140
|
+
handler: () => {},
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
expect(cliSchemaExport(root).commands?.[0]?.outputSchema).toEqual({
|
|
145
|
+
type: "object",
|
|
146
|
+
properties: { id: { type: "string" } },
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("outputSchema must be a JSON Schema object", () => {
|
|
151
|
+
const root = testProgram({
|
|
152
|
+
key: "app",
|
|
153
|
+
version: "1.0.0",
|
|
154
|
+
description: "Bad output schema.",
|
|
155
|
+
commands: [
|
|
156
|
+
{
|
|
157
|
+
key: "run",
|
|
158
|
+
description: "Run.",
|
|
159
|
+
outputSchema: [] as unknown as Record<string, unknown>,
|
|
160
|
+
handler: () => {},
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
});
|
|
164
|
+
expect(() => cliValidateProgram(root)).toThrow(/outputSchema must be a JSON Schema object/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("outputSchema cannot be set on both leaf and mcpTool", () => {
|
|
168
|
+
const root = testProgram({
|
|
169
|
+
key: "app",
|
|
170
|
+
version: "1.0.0",
|
|
171
|
+
description: "Duplicate output schema.",
|
|
172
|
+
commands: [
|
|
173
|
+
{
|
|
174
|
+
key: "run",
|
|
175
|
+
description: "Run.",
|
|
176
|
+
outputSchema: { type: "object" },
|
|
177
|
+
mcpTool: { outputSchema: { type: "object" } },
|
|
178
|
+
handler: () => {},
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
});
|
|
182
|
+
expect(() => cliValidateProgram(root)).toThrow(/Set outputSchema on the leaf only/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("collectMcpTools merges parent options into inputSchema", () => {
|
|
186
|
+
const tools = collectMcpTools(nestedMcpFixture);
|
|
187
|
+
const lookup = tools.find((t) => t.name === "stat_owner_lookup")!;
|
|
188
|
+
const schema = lookup.inputSchema as { properties: Record<string, unknown>; required?: string[] };
|
|
189
|
+
expect(schema.properties.json).toBeDefined();
|
|
190
|
+
expect(schema.required).toContain("path");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("collectMcpTools includes outputSchema when set on leaf", () => {
|
|
194
|
+
const root = testProgram({
|
|
195
|
+
key: "app",
|
|
196
|
+
version: "1.0.0",
|
|
197
|
+
description: "Output schema demo.",
|
|
198
|
+
mcpServer: { enabled: true },
|
|
199
|
+
commands: [
|
|
200
|
+
{
|
|
201
|
+
key: "run",
|
|
202
|
+
description: "Run with JSON output.",
|
|
203
|
+
outputSchema: {
|
|
204
|
+
type: "object",
|
|
205
|
+
properties: { ok: { type: "boolean" } },
|
|
206
|
+
required: ["ok"],
|
|
207
|
+
},
|
|
208
|
+
handler: () => {},
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
});
|
|
212
|
+
const tools = collectMcpTools(root);
|
|
213
|
+
expect(tools).toHaveLength(1);
|
|
214
|
+
expect(tools[0]?.outputSchema).toEqual({
|
|
215
|
+
type: "object",
|
|
216
|
+
properties: { ok: { type: "boolean" } },
|
|
217
|
+
required: ["ok"],
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("collectMcpTools omits outputSchema when leaf has none", () => {
|
|
222
|
+
const tools = collectMcpTools(nestedMcpFixture);
|
|
223
|
+
const lookup = tools.find((t) => t.name === "stat_owner_lookup")!;
|
|
224
|
+
expect(lookup.outputSchema).toBeUndefined();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("mcpToolCallToArgv builds nested lookup argv", () => {
|
|
228
|
+
const tools = collectMcpTools(nestedMcpFixture);
|
|
229
|
+
const lookup = tools.find((t) => t.name === "stat_owner_lookup")!;
|
|
230
|
+
const argv = mcpToolCallToArgv(nestedMcpFixture, lookup, {
|
|
231
|
+
"user-name": "alice",
|
|
232
|
+
path: "./x",
|
|
233
|
+
json: true,
|
|
234
|
+
});
|
|
235
|
+
expect(argv).toEqual(["stat", "owner", "lookup", "--json", "--user-name", "alice", "./x"]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("mcpToolCallToArgv expands varargs positionals", () => {
|
|
239
|
+
const tools = collectMcpTools(nestedMcpFixture);
|
|
240
|
+
const read = tools.find((t) => t.name === "read")!;
|
|
241
|
+
const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: ["a", "b"] });
|
|
242
|
+
expect(argv).toEqual(["read", "a", "b"]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("reserved command name install is rejected", () => {
|
|
246
|
+
const root = testProgram({
|
|
247
|
+
key: "app",
|
|
248
|
+
description: "",
|
|
249
|
+
commands: [
|
|
250
|
+
{
|
|
251
|
+
key: "install",
|
|
252
|
+
description: "bad",
|
|
253
|
+
handler: () => {},
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
expect(() => cliValidateProgram(root)).toThrow(/Reserved command name: install/);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("top-level command name mcp is allowed without mcpServer", () => {
|
|
261
|
+
const root = testProgram({
|
|
262
|
+
key: "app",
|
|
263
|
+
description: "",
|
|
264
|
+
commands: [
|
|
265
|
+
{
|
|
266
|
+
key: "mcp",
|
|
267
|
+
description: "user command",
|
|
268
|
+
handler: () => {},
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
});
|
|
272
|
+
expect(() => cliValidateProgram(root)).not.toThrow();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("top-level command name mcp is rejected when mcpServer is enabled", () => {
|
|
276
|
+
const root = testProgram({
|
|
277
|
+
key: "app",
|
|
278
|
+
description: "",
|
|
279
|
+
mcpServer: { enabled: true },
|
|
280
|
+
commands: [
|
|
281
|
+
{
|
|
282
|
+
key: "mcp",
|
|
283
|
+
description: "user command",
|
|
284
|
+
handler: () => {},
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
});
|
|
288
|
+
expect(() => cliValidateProgram(root)).toThrow(/Reserved command name: mcp/);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("mcpServer on non-root node is rejected", () => {
|
|
292
|
+
const root = {
|
|
293
|
+
key: "app",
|
|
294
|
+
version: "0.0.0",
|
|
295
|
+
description: "",
|
|
296
|
+
commands: [
|
|
297
|
+
{
|
|
298
|
+
key: "x",
|
|
299
|
+
description: "cmd",
|
|
300
|
+
mcpServer: { enabled: true },
|
|
301
|
+
handler: () => {},
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
} as unknown as CliProgram;
|
|
305
|
+
expect(() => cliValidateProgram(root)).toThrow(/mcpServer is only supported on the program root/);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("mcpTool on root is rejected", () => {
|
|
309
|
+
const root = testProgram({
|
|
310
|
+
key: "app",
|
|
311
|
+
description: "",
|
|
312
|
+
mcpTool: { enabled: false },
|
|
313
|
+
handler: () => {},
|
|
314
|
+
});
|
|
315
|
+
expect(() => cliValidateProgram(root)).toThrow(/mcpTool is only supported on leaf commands/);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("mcpTool on routing node is rejected", () => {
|
|
319
|
+
const root = testProgram({
|
|
320
|
+
key: "app",
|
|
321
|
+
description: "",
|
|
322
|
+
commands: [
|
|
323
|
+
{
|
|
324
|
+
key: "group",
|
|
325
|
+
description: "group",
|
|
326
|
+
mcpTool: { enabled: false },
|
|
327
|
+
commands: [
|
|
328
|
+
{
|
|
329
|
+
key: "leaf",
|
|
330
|
+
description: "leaf",
|
|
331
|
+
handler: () => {},
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
});
|
|
337
|
+
expect(() => cliValidateProgram(root)).toThrow(/mcpTool is only supported on leaf commands/);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("buildToolCallSuccess returns stdout only", () => {
|
|
341
|
+
const result = buildToolCallSuccess("hello\n", "");
|
|
342
|
+
expect(result.isError).toBe(false);
|
|
343
|
+
expect(result.content).toEqual([{ type: "text", text: "hello\n" }]);
|
|
344
|
+
expect(result.structuredContent).toBeUndefined();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("buildToolCallSuccess adds stderr as second content block", () => {
|
|
348
|
+
const result = buildToolCallSuccess("out\n", "warn\n");
|
|
349
|
+
expect(result.content).toEqual([
|
|
350
|
+
{ type: "text", text: "out\n" },
|
|
351
|
+
{ type: "text", text: "warn" },
|
|
352
|
+
]);
|
|
353
|
+
expect(result.structuredContent).toBeUndefined();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("buildToolCallSuccess stderr-only still includes stdout slot", () => {
|
|
357
|
+
const result = buildToolCallSuccess("", "warn\n");
|
|
358
|
+
expect(result.content).toEqual([
|
|
359
|
+
{ type: "text", text: "" },
|
|
360
|
+
{ type: "text", text: "warn" },
|
|
361
|
+
]);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("buildToolCallSuccess parses JSON structuredContent", () => {
|
|
365
|
+
const result = buildToolCallSuccess('{"a":1}\n', "");
|
|
366
|
+
expect(result.structuredContent).toEqual({ a: 1 });
|
|
367
|
+
expect(result.content[0]?.text).toBe('{"a":1}\n');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("buildToolCallSuccess skips structuredContent for plain text", () => {
|
|
371
|
+
const result = buildToolCallSuccess("lookup user=x\n", "");
|
|
372
|
+
expect(result.structuredContent).toBeUndefined();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("buildToolCallSuccess parses JSON primitives", () => {
|
|
376
|
+
const result = buildToolCallSuccess("true\n", "");
|
|
377
|
+
expect(result.structuredContent).toBe(true);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("MCP initialize returns tools and resources capabilities", async () => {
|
|
381
|
+
const responses = await mcpRequest([{ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }]);
|
|
382
|
+
const res = responses.get(1) as { result: { capabilities: Record<string, unknown> } };
|
|
383
|
+
expect(res.result.capabilities.tools).toBeDefined();
|
|
384
|
+
expect(res.result.capabilities.resources).toBeDefined();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("MCP tools/list includes stat_owner_lookup", async () => {
|
|
388
|
+
const responses = await mcpRequest([{ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }]);
|
|
389
|
+
const res = responses.get(2) as {
|
|
390
|
+
result: { tools: { name: string; inputSchema: { required?: string[] } }[] };
|
|
391
|
+
};
|
|
392
|
+
const lookup = res.result.tools.find((t) => t.name === "stat_owner_lookup");
|
|
393
|
+
expect(lookup).toBeDefined();
|
|
394
|
+
expect(lookup?.inputSchema.required).toContain("path");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("MCP resources/read returns schema JSON", async () => {
|
|
398
|
+
const responses = await mcpRequest([
|
|
399
|
+
{ jsonrpc: "2.0", id: 3, method: "resources/read", params: { uri: "nested_ts://schema" } },
|
|
400
|
+
]);
|
|
401
|
+
const res = responses.get(3) as { result: { contents: { text: string }[] } };
|
|
402
|
+
const schema = JSON.parse(res.result.contents[0]?.text);
|
|
403
|
+
expect(schema.key).toBe("nested.ts");
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("MCP tools/call runs stat_owner_lookup", async () => {
|
|
407
|
+
const readme = join(import.meta.dir, "..", "README.md");
|
|
408
|
+
const responses = await mcpRequest([
|
|
409
|
+
{
|
|
410
|
+
jsonrpc: "2.0",
|
|
411
|
+
id: 4,
|
|
412
|
+
method: "tools/call",
|
|
413
|
+
params: {
|
|
414
|
+
name: "stat_owner_lookup",
|
|
415
|
+
arguments: { path: readme, "user-name": "test" },
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
]);
|
|
419
|
+
const res = responses.get(4) as { result: { content: { text: string }[]; isError: boolean } };
|
|
420
|
+
expect(res.result.isError).toBe(false);
|
|
421
|
+
expect(res.result.content[0]?.text).toContain("lookup user=test");
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("MCP tools/call returns structuredContent for JSON stdout", async () => {
|
|
425
|
+
const readme = join(import.meta.dir, "..", "README.md");
|
|
426
|
+
const responses = await mcpRequest([
|
|
427
|
+
{
|
|
428
|
+
jsonrpc: "2.0",
|
|
429
|
+
id: 6,
|
|
430
|
+
method: "tools/call",
|
|
431
|
+
params: {
|
|
432
|
+
name: "stat_owner_lookup",
|
|
433
|
+
arguments: { path: readme, "user-name": "test", json: true },
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
]);
|
|
437
|
+
const res = responses.get(6) as {
|
|
438
|
+
result: {
|
|
439
|
+
content: { text: string }[];
|
|
440
|
+
structuredContent?: { user: string; path: string };
|
|
441
|
+
isError: boolean;
|
|
442
|
+
};
|
|
443
|
+
};
|
|
444
|
+
expect(res.result.isError).toBe(false);
|
|
445
|
+
expect(res.result.structuredContent).toEqual({ user: "test", path: readme });
|
|
446
|
+
expect(JSON.parse(res.result.content[0]?.text.trim())).toEqual({ user: "test", path: readme });
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("MCP tools/call errors on missing required positional", async () => {
|
|
450
|
+
const responses = await mcpRequest([
|
|
451
|
+
{
|
|
452
|
+
jsonrpc: "2.0",
|
|
453
|
+
id: 5,
|
|
454
|
+
method: "tools/call",
|
|
455
|
+
params: { name: "stat_owner_lookup", arguments: { "user-name": "test" } },
|
|
456
|
+
},
|
|
457
|
+
]);
|
|
458
|
+
const res = responses.get(5) as { result: { isError: boolean; content: { text: string }[] } };
|
|
459
|
+
expect(res.result.isError).toBe(true);
|
|
460
|
+
expect(res.result.content[0]?.text).toContain("Missing argument: path");
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("MCP ping returns empty result", async () => {
|
|
464
|
+
const responses = await mcpRequest([{ jsonrpc: "2.0", id: 99, method: "ping", params: {} }]);
|
|
465
|
+
const res = responses.get(99) as { result: Record<string, never> };
|
|
466
|
+
expect(res.result).toEqual({});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("minimal.ts mcp without opt-in fails", async () => {
|
|
470
|
+
const { stderr, exitCode } = await $`bun run examples/minimal.ts mcp`.nothrow().quiet();
|
|
471
|
+
expect(exitCode).toBe(1);
|
|
472
|
+
expect(stderr.toString()).toContain("MCP is not enabled");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("MCP resources/list includes custom resource", async () => {
|
|
476
|
+
const responses = await mcpRequest(
|
|
477
|
+
[{ jsonrpc: "2.0", id: 10, method: "resources/list", params: {} }],
|
|
478
|
+
{ script: "examples/mcp-test.ts" },
|
|
479
|
+
);
|
|
480
|
+
const res = responses.get(10) as { result: { resources: { uri: string }[] } };
|
|
481
|
+
const uris = res.result.resources.map((r) => r.uri);
|
|
482
|
+
expect(uris).toContain("mcp_test://schema");
|
|
483
|
+
expect(uris).toContain("test://hello");
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("MCP resources/read returns custom resource body", async () => {
|
|
487
|
+
const responses = await mcpRequest(
|
|
488
|
+
[{ jsonrpc: "2.0", id: 11, method: "resources/read", params: { uri: "test://hello" } }],
|
|
489
|
+
{ script: "examples/mcp-test.ts" },
|
|
490
|
+
);
|
|
491
|
+
const res = responses.get(11) as { result: { contents: { text: string }[] } };
|
|
492
|
+
expect(res.result.contents[0]?.text).toBe("hello resource");
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("MCP resources/read unknown URI returns error", async () => {
|
|
496
|
+
const responses = await mcpRequest(
|
|
497
|
+
[{ jsonrpc: "2.0", id: 12, method: "resources/read", params: { uri: "missing://nope" } }],
|
|
498
|
+
{ script: "examples/mcp-test.ts" },
|
|
499
|
+
);
|
|
500
|
+
const res = responses.get(12) as { error: { code: number } };
|
|
501
|
+
expect(res.error.code).toBe(-32602);
|
|
502
|
+
});
|