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.
Files changed (98) hide show
  1. package/CHANGELOG.md +28 -2
  2. package/README.md +21 -9
  3. package/docs/README.md +12 -8
  4. package/docs/bundled-docs.md +1 -1
  5. package/docs/cli-program.md +62 -2
  6. package/docs/config-schema.md +192 -0
  7. package/docs/developing.md +13 -0
  8. package/docs/install.md +38 -1
  9. package/docs/mcp.md +43 -19
  10. package/docs/output-schema.md +74 -52
  11. package/docs/templates/cursor/rules/cli-program.mdc +10 -5
  12. package/examples/config-app/main.ts +20 -0
  13. package/examples/config-app/program.ts +81 -0
  14. package/examples/config-app/schema.ts +37 -0
  15. package/examples/config-app/types.ts +19 -0
  16. package/examples/consumer-app/README.md +56 -0
  17. package/examples/consumer-app/bun.lock +75 -0
  18. package/examples/consumer-app/capabilities.test.ts +69 -0
  19. package/examples/consumer-app/package.json +17 -0
  20. package/examples/consumer-app/schemas/configSchemas.ts +6 -0
  21. package/examples/consumer-app/schemas/generated/app-config.json +40 -0
  22. package/examples/consumer-app/schemas/generated/status.json +28 -0
  23. package/examples/consumer-app/schemas/outputSchemas.ts +6 -0
  24. package/examples/consumer-app/scripts/schemagen/discover-schema-roots.test.ts +25 -0
  25. package/examples/consumer-app/scripts/schemagen/discover-schema-roots.ts +93 -0
  26. package/examples/consumer-app/scripts/schemagen/naming.ts +82 -0
  27. package/examples/consumer-app/scripts/schemagen.ts +76 -0
  28. package/examples/consumer-app/src/commands/status/types.ts +11 -0
  29. package/examples/consumer-app/src/main.ts +15 -0
  30. package/examples/consumer-app/src/program.ts +116 -0
  31. package/examples/consumer-app/src/types.ts +23 -0
  32. package/examples/consumer-app/tsconfig.json +14 -0
  33. package/examples/formats.ts +10 -3
  34. package/examples/mcp-test.ts +27 -8
  35. package/examples/minimal.ts +4 -3
  36. package/examples/nested.ts +5 -4
  37. package/examples/option-required.ts +8 -4
  38. package/index.d.ts +152 -75
  39. package/package.json +1 -1
  40. package/src/builtins/builtins.test.ts +13 -0
  41. package/src/builtins/config.test.ts +82 -0
  42. package/src/builtins/config.ts +220 -0
  43. package/src/builtins/dispatch.ts +18 -9
  44. package/src/builtins/export.ts +8 -33
  45. package/src/builtins/index.ts +1 -0
  46. package/src/builtins/install.ts +13 -0
  47. package/src/builtins/mcp.ts +1 -1
  48. package/src/builtins/presentation.ts +2 -17
  49. package/src/builtins/registry.ts +40 -0
  50. package/src/capabilities.ts +46 -0
  51. package/src/cli-errors.ts +15 -0
  52. package/src/cli.ts +389 -0
  53. package/src/config/bootstrap.ts +265 -0
  54. package/src/config/context.test.ts +61 -0
  55. package/src/config/context.ts +98 -0
  56. package/src/config/entry.ts +81 -0
  57. package/src/config/file.test.ts +112 -0
  58. package/src/config/file.ts +120 -0
  59. package/src/config/manifest.ts +62 -0
  60. package/src/config/resolve.test.ts +88 -0
  61. package/src/config/resolve.ts +167 -0
  62. package/src/config/schema.ts +101 -0
  63. package/src/config/validate.test.ts +63 -0
  64. package/src/config/validate.ts +292 -0
  65. package/src/config.integration.test.ts +100 -0
  66. package/src/context.ts +5 -0
  67. package/src/docs/docs.test.ts +16 -16
  68. package/src/docs/mcp-guide.ts +35 -11
  69. package/src/hidden-mcpb.test.ts +40 -2
  70. package/src/index.ts +4 -3
  71. package/src/install/index.ts +46 -3
  72. package/src/install/paths.ts +5 -20
  73. package/src/install/plan.ts +6 -0
  74. package/src/install/status.ts +12 -0
  75. package/src/install/uninstall.ts +11 -0
  76. package/src/install/update.test.ts +5 -5
  77. package/src/invoke.test.ts +207 -0
  78. package/src/mcp/bundle.ts +9 -116
  79. package/src/mcp/claude.test.ts +73 -0
  80. package/src/mcp/claude.ts +168 -0
  81. package/src/mcp/env.ts +3 -37
  82. package/src/mcp/server.ts +18 -10
  83. package/src/mcp/tools.ts +3 -7
  84. package/src/mcp/zip.ts +82 -0
  85. package/src/mcp.integration.test.ts +502 -0
  86. package/src/{index.test.ts → parse.test.ts} +24 -935
  87. package/src/paths/host.ts +40 -0
  88. package/src/schema.ts +1 -1
  89. package/src/skill/generate.ts +18 -5
  90. package/src/skill/hint.ts +18 -0
  91. package/src/skill/install.ts +1 -5
  92. package/src/test-fixtures.ts +192 -0
  93. package/src/types.ts +39 -13
  94. package/src/validate.ts +70 -0
  95. package/src/completion.ts +0 -13
  96. package/src/invoke.ts +0 -217
  97. package/src/mcp.ts +0 -28
  98. 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 { cliInvoke } from "../invoke.ts";
7
- import type { CliProgram } from "../types.ts";
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(root: CliProgram, line: string): Promise<void> {
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 missingEnv = (tool.leaf.mcpTool?.requiresEnv ?? []).filter((k) => !process.env[k]);
125
- if (missingEnv.length > 0) {
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: [{ type: "text", text: `Missing required env: ${missingEnv.join(", ")}` }],
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 cliInvoke(root, argvResult);
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(root: CliProgram): Promise<void> {
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(root, line);
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(root, trailing);
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 cliInvoke.
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, requiresEnv suffix, and leaf notes. */
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 cliInvoke. */
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
+ });