argsbarg 1.3.0 → 1.4.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/.cursor/plans/mcp_v1.1_polish_e9656029.plan.md +260 -0
- package/.private/scratch.md +1 -1
- package/CHANGELOG.md +24 -1
- package/README.md +17 -2
- package/docs/mcp.md +211 -0
- package/examples/nested.ts +1 -0
- package/index.d.ts +22 -0
- package/justfile +4 -4
- package/package.json +1 -1
- package/src/completion.ts +61 -2
- package/src/help.ts +8 -4
- package/src/index.test.ts +422 -0
- package/src/index.ts +1 -1
- package/src/invoke.ts +192 -0
- package/src/mcp/result.ts +57 -0
- package/src/mcp/server.ts +226 -0
- package/src/mcp/tools.ts +209 -0
- package/src/mcp.ts +24 -0
- package/src/parse.ts +2 -2
- package/src/runtime.ts +29 -7
- package/src/schema.ts +16 -0
- package/src/types.ts +24 -0
- package/src/validate.ts +16 -1
package/src/completion.ts
CHANGED
|
@@ -77,6 +77,8 @@ function mainName(schemaName: string): string {
|
|
|
77
77
|
|
|
78
78
|
const kHelpLong = "--help";
|
|
79
79
|
const kHelpShort = "-h";
|
|
80
|
+
const kSchemaLong = "--schema";
|
|
81
|
+
const kSchemaDesc = "Print the full command tree as JSON.";
|
|
80
82
|
|
|
81
83
|
// ── Bash Completion ────────────────────────────────────────────────────────────
|
|
82
84
|
|
|
@@ -89,6 +91,9 @@ function emitConsumeLong(ident: string, scopes: ScopeRec[]): string {
|
|
|
89
91
|
o += " " + i + ")\n";
|
|
90
92
|
o += " case $w in\n";
|
|
91
93
|
o += " " + kHelpLong + "|${kHelpLong}=*|${kHelpShort}) echo 1 ;;\n".replace(/\$\{kHelpLong\}/g, kHelpLong).replace(/\$\{kHelpShort\}/g, kHelpShort);
|
|
94
|
+
if (sc.path === "") {
|
|
95
|
+
o += " " + kSchemaLong + ") echo 1 ;;\n";
|
|
96
|
+
}
|
|
92
97
|
for (const op of sc.opts) {
|
|
93
98
|
const base = "--" + op.name;
|
|
94
99
|
if (op.kind === "presence") {
|
|
@@ -179,7 +184,7 @@ function emitSimulate(ident: string): string {
|
|
|
179
184
|
o += " local i=1 sid=0 w steps next\n";
|
|
180
185
|
o += " while (( i < COMP_CWORD )); do\n";
|
|
181
186
|
o += " w=\"${COMP_WORDS[i]}\"\n";
|
|
182
|
-
|
|
187
|
+
o += " if [[ $w == " + kHelpShort + " || $w == " + kHelpLong + " || $w == " + kSchemaLong + " ]]; then\n";
|
|
183
188
|
o += " ((i++)); continue\n";
|
|
184
189
|
o += " fi\n";
|
|
185
190
|
o += " if [[ $w == --* ]]; then\n";
|
|
@@ -260,6 +265,9 @@ export function completionBashScript(schema: CliCommand): string {
|
|
|
260
265
|
for (const [i, sc] of scopes.entries()) {
|
|
261
266
|
out += "A_" + ident + "_" + i + "_opts=()\n";
|
|
262
267
|
out += "A_" + ident + "_" + i + "_opts+=('" + kHelpLong + "' '" + kHelpShort + "')\n";
|
|
268
|
+
if (sc.path === "") {
|
|
269
|
+
out += "A_" + ident + "_" + i + "_opts+=('" + kSchemaLong + "')\n";
|
|
270
|
+
}
|
|
263
271
|
for (const o of sc.opts) {
|
|
264
272
|
out += "A_" + ident + "_" + i + "_opts+=('--" + o.name + "')\n";
|
|
265
273
|
if (o.shortName) {
|
|
@@ -295,6 +303,14 @@ function emitScopeArraysZsh(ident: string, scopes: ScopeRec[]): string {
|
|
|
295
303
|
out += "typeset -g A_" + ident + "_" + i + "_opts\n";
|
|
296
304
|
out += "A_" + ident + "_" + i + "_opts=(";
|
|
297
305
|
out += "'" + escShellSingleQuoted(kHelpLong) + ":" + escShellSingleQuoted("Show help for this command.") + "' '" + escShellSingleQuoted(kHelpShort) + ":" + escShellSingleQuoted("Show help for this command.") + "'";
|
|
306
|
+
if (sc.path === "") {
|
|
307
|
+
out +=
|
|
308
|
+
" '" +
|
|
309
|
+
escShellSingleQuoted(kSchemaLong) +
|
|
310
|
+
":" +
|
|
311
|
+
escShellSingleQuoted(kSchemaDesc) +
|
|
312
|
+
"'";
|
|
313
|
+
}
|
|
298
314
|
for (const o of sc.opts) {
|
|
299
315
|
out += " '" + escShellSingleQuoted("--" + o.name) + ":" + escShellSingleQuoted(o.description) + "'";
|
|
300
316
|
if (o.shortName) {
|
|
@@ -329,6 +345,9 @@ function emitConsumeLongZsh(ident: string, scopes: ScopeRec[]): string {
|
|
|
329
345
|
o += " " + i + ")\n";
|
|
330
346
|
o += " case $w in\n";
|
|
331
347
|
o += " " + kHelpLong + "|${kHelpLong}=*|${kHelpShort}) echo 1 ;;\n".replace(/\$\{kHelpLong\}/g, kHelpLong).replace(/\$\{kHelpShort\}/g, kHelpShort);
|
|
348
|
+
if (sc.path === "") {
|
|
349
|
+
o += " " + kSchemaLong + ") echo 1 ;;\n";
|
|
350
|
+
}
|
|
332
351
|
for (const op of sc.opts) {
|
|
333
352
|
const base = "--" + op.name;
|
|
334
353
|
if (op.kind === "presence") {
|
|
@@ -419,7 +438,7 @@ function emitSimulateZsh(ident: string): string {
|
|
|
419
438
|
o += " local i=2 sid=0 w steps next\n";
|
|
420
439
|
o += " while (( i < CURRENT )); do\n";
|
|
421
440
|
o += " w=$words[i]\n";
|
|
422
|
-
|
|
441
|
+
o += " if [[ $w == " + kHelpShort + " || $w == " + kHelpLong + " || $w == " + kSchemaLong + " ]]; then\n";
|
|
423
442
|
o += " ((i++)); continue\n";
|
|
424
443
|
o += " fi\n";
|
|
425
444
|
o += " if [[ $w == --* ]]; then\n";
|
|
@@ -502,6 +521,46 @@ export function completionZshScript(schema: CliCommand): string {
|
|
|
502
521
|
return out;
|
|
503
522
|
}
|
|
504
523
|
|
|
524
|
+
/**
|
|
525
|
+
* Returns a schema suitable for help display, including reserved built-in subtrees.
|
|
526
|
+
* Routing roots get `completion` merged; leaf roots are wrapped as a tiny router.
|
|
527
|
+
*/
|
|
528
|
+
export function cliPresentationRoot(root: CliCommand): CliCommand {
|
|
529
|
+
if ((root.commands ?? []).some((c) => c.key === "completion")) {
|
|
530
|
+
return root;
|
|
531
|
+
}
|
|
532
|
+
if ("handler" in root && root.handler) {
|
|
533
|
+
return {
|
|
534
|
+
key: root.key,
|
|
535
|
+
description: root.description,
|
|
536
|
+
options: root.options,
|
|
537
|
+
commands: presentationBuiltins(root),
|
|
538
|
+
} as CliCommand;
|
|
539
|
+
}
|
|
540
|
+
return {
|
|
541
|
+
...root,
|
|
542
|
+
commands: [...(root.commands ?? []), ...presentationBuiltins(root)],
|
|
543
|
+
} as CliCommand;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Built-in commands shown in help and merged for routing CLIs. */
|
|
547
|
+
function presentationBuiltins(root: CliCommand): CliCommand[] {
|
|
548
|
+
const cmds: CliCommand[] = [cliBuiltinCompletionGroup(root.key)];
|
|
549
|
+
if (root.mcpServer !== undefined) {
|
|
550
|
+
cmds.push(cliBuiltinMcpCommand());
|
|
551
|
+
}
|
|
552
|
+
return cmds;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Builds the static `mcp` leaf command (merged when `root.mcpServer` is set). */
|
|
556
|
+
export function cliBuiltinMcpCommand(): CliCommand {
|
|
557
|
+
return {
|
|
558
|
+
key: "mcp",
|
|
559
|
+
description: "Run as an MCP server over stdio (for AI agents).",
|
|
560
|
+
handler: () => {},
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
505
564
|
/**
|
|
506
565
|
* Builds the static `completion` / `bash` / `zsh` command subtree (merged into the program root at runtime).
|
|
507
566
|
*/
|
package/src/help.ts
CHANGED
|
@@ -335,13 +335,17 @@ function usageLines(
|
|
|
335
335
|
return out;
|
|
336
336
|
}
|
|
337
337
|
|
|
338
|
-
/** Table rows for named options, including
|
|
339
|
-
function rowsForOptions(defs: CliOption[], color: boolean): HelpRow[] {
|
|
338
|
+
/** Table rows for named options, including synthetic built-in rows. */
|
|
339
|
+
function rowsForOptions(defs: CliOption[], color: boolean, isRoot: boolean): HelpRow[] {
|
|
340
340
|
const rows: HelpRow[] = [];
|
|
341
341
|
const helpLabel = color
|
|
342
342
|
? style.aquaBold("--help, ") + style.greenBright("-h")
|
|
343
343
|
: "--help, -h";
|
|
344
344
|
rows.push({ label: helpLabel, description: "Show help for this command." });
|
|
345
|
+
if (isRoot) {
|
|
346
|
+
const schemaLabel = color ? style.aquaBold("--schema") : "--schema";
|
|
347
|
+
rows.push({ label: schemaLabel, description: "Print the full command tree as JSON." });
|
|
348
|
+
}
|
|
345
349
|
for (const o of defs) {
|
|
346
350
|
const desc = o.required ? "(required) " + o.description : o.description;
|
|
347
351
|
rows.push({ label: cliOptionLabel(o, color), description: desc });
|
|
@@ -387,7 +391,7 @@ export function cliHelpRender(schema: CliCommand, helpPath: string[], useStderr:
|
|
|
387
391
|
).join("\n"),
|
|
388
392
|
);
|
|
389
393
|
|
|
390
|
-
const optBox = renderTableBox("Options", rowsForOptions(schema.options ?? [], color), hw, color);
|
|
394
|
+
const optBox = renderTableBox("Options", rowsForOptions(schema.options ?? [], color, true), hw, color);
|
|
391
395
|
if (optBox.length > 0) {
|
|
392
396
|
lines.push("");
|
|
393
397
|
lines.push(optBox.join("\n"));
|
|
@@ -430,7 +434,7 @@ export function cliHelpRender(schema: CliCommand, helpPath: string[], useStderr:
|
|
|
430
434
|
).join("\n"),
|
|
431
435
|
);
|
|
432
436
|
|
|
433
|
-
const optBox = renderTableBox("Options", rowsForOptions(node.options ?? [], color), hw, color);
|
|
437
|
+
const optBox = renderTableBox("Options", rowsForOptions(node.options ?? [], color, false), hw, color);
|
|
434
438
|
if (optBox.length > 0) {
|
|
435
439
|
lines.push("");
|
|
436
440
|
lines.push(optBox.join("\n"));
|
package/src/index.test.ts
CHANGED
|
@@ -8,12 +8,21 @@ shell output regressions.
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { completionBashScript, completionZshScript } from "./completion.ts";
|
|
11
|
+
import { cliHelpRender } from "./help.ts";
|
|
11
12
|
import { CliCommand, CliFallbackMode, CliOptionKind } from "./index.ts";
|
|
13
|
+
import {
|
|
14
|
+
collectMcpTools,
|
|
15
|
+
mcpToolCallToArgv,
|
|
16
|
+
mcpToolDescription,
|
|
17
|
+
sanitizeToolSegment,
|
|
18
|
+
} from "./mcp/tools.ts";
|
|
19
|
+
import { buildToolCallSuccess } from "./mcp/result.ts";
|
|
12
20
|
import { ParseKind, parse, postParseValidate } from "./parse.ts";
|
|
13
21
|
import { cliSchemaJson } from "./schema.ts";
|
|
14
22
|
import { cliValidateRoot } from "./validate.ts";
|
|
15
23
|
import { expect, test } from "bun:test";
|
|
16
24
|
import { $ } from "bun";
|
|
25
|
+
import { join } from "node:path";
|
|
17
26
|
|
|
18
27
|
test("bundled short presence flags", () => {
|
|
19
28
|
const root: CliCommand = {
|
|
@@ -501,6 +510,14 @@ test("--schema exports JSON for leaf roots", async () => {
|
|
|
501
510
|
expect(schema.key).toBe("minimal.ts");
|
|
502
511
|
expect(schema.positionals[0].name).toBe("name");
|
|
503
512
|
expect(schema.options[0].name).toBe("verbose");
|
|
513
|
+
expect(schema.commands.map((c: { key: string }) => c.key)).toEqual(["completion"]);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("leaf root help lists completion built-in", async () => {
|
|
517
|
+
const { stdout, exitCode } = await $`bun run examples/minimal.ts -h`.nothrow().quiet();
|
|
518
|
+
expect(exitCode).toBe(0);
|
|
519
|
+
expect(stdout.toString()).toContain("completion");
|
|
520
|
+
expect(stdout.toString()).toContain("Generate the autocompletion script for shells.");
|
|
504
521
|
});
|
|
505
522
|
|
|
506
523
|
test("parse recognizes --schema at the program root", () => {
|
|
@@ -570,4 +587,409 @@ test("reserved option name schema is rejected", () => {
|
|
|
570
587
|
],
|
|
571
588
|
};
|
|
572
589
|
expect(() => cliValidateRoot(root)).toThrow(/reserved for --schema/);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("root help lists --schema built-in", () => {
|
|
593
|
+
const root: CliCommand = {
|
|
594
|
+
key: "app",
|
|
595
|
+
description: "demo",
|
|
596
|
+
commands: [
|
|
597
|
+
{
|
|
598
|
+
key: "x",
|
|
599
|
+
description: "cmd",
|
|
600
|
+
handler: () => {},
|
|
601
|
+
},
|
|
602
|
+
],
|
|
603
|
+
};
|
|
604
|
+
const help = cliHelpRender(root, [], false);
|
|
605
|
+
expect(help).toContain("--schema");
|
|
606
|
+
expect(help).toContain("Print the full command tree as JSON.");
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("nested help omits --schema built-in", () => {
|
|
610
|
+
const root: CliCommand = {
|
|
611
|
+
key: "app",
|
|
612
|
+
description: "demo",
|
|
613
|
+
commands: [
|
|
614
|
+
{
|
|
615
|
+
key: "x",
|
|
616
|
+
description: "cmd",
|
|
617
|
+
handler: () => {},
|
|
618
|
+
},
|
|
619
|
+
],
|
|
620
|
+
};
|
|
621
|
+
const help = cliHelpRender(root, ["x"], false);
|
|
622
|
+
expect(help).not.toContain("--schema");
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("completion scripts offer --schema at the program root only", () => {
|
|
626
|
+
const root: CliCommand = {
|
|
627
|
+
key: "myapp",
|
|
628
|
+
description: "",
|
|
629
|
+
commands: [
|
|
630
|
+
{
|
|
631
|
+
key: "stat",
|
|
632
|
+
description: "stats",
|
|
633
|
+
commands: [
|
|
634
|
+
{
|
|
635
|
+
key: "show",
|
|
636
|
+
description: "show",
|
|
637
|
+
handler: () => {},
|
|
638
|
+
},
|
|
639
|
+
],
|
|
640
|
+
},
|
|
641
|
+
],
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
const bash = completionBashScript(root);
|
|
645
|
+
expect(bash).toContain("A_myapp_0_opts+=('--schema')");
|
|
646
|
+
expect(bash).not.toContain("A_myapp_1_opts+=('--schema')");
|
|
647
|
+
|
|
648
|
+
const zsh = completionZshScript(root);
|
|
649
|
+
expect(zsh).toContain("'--schema:Print the full command tree as JSON.'");
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
const nestedMcpFixture: CliCommand = {
|
|
653
|
+
key: "nested.ts",
|
|
654
|
+
description: "Nested groups demo.",
|
|
655
|
+
mcpServer: { name: "nested-demo", version: "1.0.0" },
|
|
656
|
+
commands: [
|
|
657
|
+
{
|
|
658
|
+
key: "stat",
|
|
659
|
+
description: "File metadata.",
|
|
660
|
+
options: [
|
|
661
|
+
{
|
|
662
|
+
name: "json",
|
|
663
|
+
description: "Emit handler output as JSON.",
|
|
664
|
+
kind: CliOptionKind.Presence,
|
|
665
|
+
},
|
|
666
|
+
],
|
|
667
|
+
commands: [
|
|
668
|
+
{
|
|
669
|
+
key: "owner",
|
|
670
|
+
description: "Ownership helpers.",
|
|
671
|
+
commands: [
|
|
672
|
+
{
|
|
673
|
+
key: "lookup",
|
|
674
|
+
description: "Resolve owner info.",
|
|
675
|
+
options: [
|
|
676
|
+
{
|
|
677
|
+
name: "user-name",
|
|
678
|
+
description: "User to look up.",
|
|
679
|
+
kind: CliOptionKind.String,
|
|
680
|
+
shortName: "u",
|
|
681
|
+
},
|
|
682
|
+
],
|
|
683
|
+
positionals: [
|
|
684
|
+
{
|
|
685
|
+
name: "path",
|
|
686
|
+
description: "File or directory.",
|
|
687
|
+
kind: CliOptionKind.String,
|
|
688
|
+
},
|
|
689
|
+
],
|
|
690
|
+
handler: () => {},
|
|
691
|
+
},
|
|
692
|
+
],
|
|
693
|
+
},
|
|
694
|
+
],
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
key: "read",
|
|
698
|
+
description: "Print the first line of each file.",
|
|
699
|
+
positionals: [
|
|
700
|
+
{
|
|
701
|
+
name: "files",
|
|
702
|
+
description: "Paths to read.",
|
|
703
|
+
kind: CliOptionKind.String,
|
|
704
|
+
argMax: 0,
|
|
705
|
+
},
|
|
706
|
+
],
|
|
707
|
+
handler: () => {},
|
|
708
|
+
},
|
|
709
|
+
{
|
|
710
|
+
key: "hidden",
|
|
711
|
+
description: "Internal debug.",
|
|
712
|
+
mcpTool: { enabled: false },
|
|
713
|
+
handler: () => {},
|
|
714
|
+
},
|
|
715
|
+
],
|
|
716
|
+
fallbackCommand: "read",
|
|
717
|
+
fallbackMode: CliFallbackMode.MissingOrUnknown,
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
/** Sends NDJSON MCP requests to a subprocess and collects responses by id. */
|
|
721
|
+
async function mcpRequest(requests: object[]): Promise<Map<string | number, object>> {
|
|
722
|
+
const proc = Bun.spawn(["bun", "run", "examples/nested.ts", "mcp"], {
|
|
723
|
+
stdin: "pipe",
|
|
724
|
+
stdout: "pipe",
|
|
725
|
+
stderr: "pipe",
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
const input = requests.map((r) => JSON.stringify(r) + "\n").join("");
|
|
729
|
+
proc.stdin.write(input);
|
|
730
|
+
proc.stdin.end();
|
|
731
|
+
|
|
732
|
+
const timeout = setTimeout(() => proc.kill(), 10_000);
|
|
733
|
+
const stdout = await new Response(proc.stdout).text();
|
|
734
|
+
await proc.exited;
|
|
735
|
+
clearTimeout(timeout);
|
|
736
|
+
|
|
737
|
+
const byId = new Map<string | number, object>();
|
|
738
|
+
for (const line of stdout.split("\n")) {
|
|
739
|
+
const trimmed = line.trim();
|
|
740
|
+
if (!trimmed) {
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
const msg = JSON.parse(trimmed) as { id?: string | number };
|
|
744
|
+
if (msg.id !== undefined) {
|
|
745
|
+
byId.set(msg.id, msg);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return byId;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
test("sanitizeToolSegment normalizes dotted app keys", () => {
|
|
752
|
+
expect(sanitizeToolSegment("minimal.ts")).toBe("minimal_ts");
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test("mcpToolDescription formats CLI path and root-leaf prefix", () => {
|
|
756
|
+
expect(mcpToolDescription(["stat", "owner", "lookup"], "nested.ts", "Resolve owner info.")).toBe(
|
|
757
|
+
"stat owner lookup — Resolve owner info.",
|
|
758
|
+
);
|
|
759
|
+
expect(mcpToolDescription(["read"], "nested.ts", "Print files.")).toBe("read — Print files.");
|
|
760
|
+
expect(mcpToolDescription([], "helloapp", "Tiny demo.")).toBe("helloapp — Tiny demo.");
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test("collectMcpTools lists user leaf commands only", () => {
|
|
764
|
+
const tools = collectMcpTools(nestedMcpFixture);
|
|
765
|
+
const names = tools.map((t) => t.name);
|
|
766
|
+
expect(names).toContain("stat_owner_lookup");
|
|
767
|
+
expect(names).toContain("read");
|
|
768
|
+
expect(names).not.toContain("hidden");
|
|
769
|
+
expect(names).not.toContain("mcp");
|
|
770
|
+
expect(names).not.toContain("completion");
|
|
771
|
+
const lookup = tools.find((t) => t.name === "stat_owner_lookup")!;
|
|
772
|
+
expect(lookup.description).toBe("stat owner lookup — Resolve owner info.");
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
test("collectMcpTools merges parent options into inputSchema", () => {
|
|
776
|
+
const tools = collectMcpTools(nestedMcpFixture);
|
|
777
|
+
const lookup = tools.find((t) => t.name === "stat_owner_lookup")!;
|
|
778
|
+
const schema = lookup.inputSchema as { properties: Record<string, unknown>; required?: string[] };
|
|
779
|
+
expect(schema.properties.json).toBeDefined();
|
|
780
|
+
expect(schema.required).toContain("path");
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
test("mcpToolCallToArgv builds nested lookup argv", () => {
|
|
784
|
+
const tools = collectMcpTools(nestedMcpFixture);
|
|
785
|
+
const lookup = tools.find((t) => t.name === "stat_owner_lookup")!;
|
|
786
|
+
const argv = mcpToolCallToArgv(nestedMcpFixture, lookup, {
|
|
787
|
+
"user-name": "alice",
|
|
788
|
+
path: "./x",
|
|
789
|
+
json: true,
|
|
790
|
+
});
|
|
791
|
+
expect(argv).toEqual(["stat", "owner", "lookup", "--json", "--user-name", "alice", "./x"]);
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
test("mcpToolCallToArgv expands varargs positionals", () => {
|
|
795
|
+
const tools = collectMcpTools(nestedMcpFixture);
|
|
796
|
+
const read = tools.find((t) => t.name === "read")!;
|
|
797
|
+
const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: ["a", "b"] });
|
|
798
|
+
expect(argv).toEqual(["read", "a", "b"]);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test("reserved command name mcp is rejected", () => {
|
|
802
|
+
const root: CliCommand = {
|
|
803
|
+
key: "app",
|
|
804
|
+
description: "",
|
|
805
|
+
commands: [
|
|
806
|
+
{
|
|
807
|
+
key: "mcp",
|
|
808
|
+
description: "bad",
|
|
809
|
+
handler: () => {},
|
|
810
|
+
},
|
|
811
|
+
],
|
|
812
|
+
};
|
|
813
|
+
expect(() => cliValidateRoot(root)).toThrow(/Reserved command name: mcp/);
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
test("mcpServer on non-root node is rejected", () => {
|
|
817
|
+
const root: CliCommand = {
|
|
818
|
+
key: "app",
|
|
819
|
+
description: "",
|
|
820
|
+
commands: [
|
|
821
|
+
{
|
|
822
|
+
key: "x",
|
|
823
|
+
description: "cmd",
|
|
824
|
+
mcpServer: {},
|
|
825
|
+
handler: () => {},
|
|
826
|
+
},
|
|
827
|
+
],
|
|
828
|
+
};
|
|
829
|
+
expect(() => cliValidateRoot(root)).toThrow(/mcpServer is only supported on the program root/);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
test("mcpTool on root is rejected", () => {
|
|
833
|
+
const root: CliCommand = {
|
|
834
|
+
key: "app",
|
|
835
|
+
description: "",
|
|
836
|
+
mcpTool: { enabled: false },
|
|
837
|
+
handler: () => {},
|
|
838
|
+
};
|
|
839
|
+
expect(() => cliValidateRoot(root)).toThrow(/mcpTool is only supported on leaf commands/);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
test("mcpTool on routing node is rejected", () => {
|
|
843
|
+
const root: CliCommand = {
|
|
844
|
+
key: "app",
|
|
845
|
+
description: "",
|
|
846
|
+
commands: [
|
|
847
|
+
{
|
|
848
|
+
key: "group",
|
|
849
|
+
description: "group",
|
|
850
|
+
mcpTool: { enabled: false },
|
|
851
|
+
commands: [
|
|
852
|
+
{
|
|
853
|
+
key: "leaf",
|
|
854
|
+
description: "leaf",
|
|
855
|
+
handler: () => {},
|
|
856
|
+
},
|
|
857
|
+
],
|
|
858
|
+
},
|
|
859
|
+
],
|
|
860
|
+
};
|
|
861
|
+
expect(() => cliValidateRoot(root)).toThrow(/mcpTool is only supported on leaf commands/);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
test("buildToolCallSuccess returns stdout only", () => {
|
|
865
|
+
const result = buildToolCallSuccess("hello\n", "");
|
|
866
|
+
expect(result.isError).toBe(false);
|
|
867
|
+
expect(result.content).toEqual([{ type: "text", text: "hello\n" }]);
|
|
868
|
+
expect(result.structuredContent).toBeUndefined();
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
test("buildToolCallSuccess adds stderr as second content block", () => {
|
|
872
|
+
const result = buildToolCallSuccess("out\n", "warn\n");
|
|
873
|
+
expect(result.content).toEqual([
|
|
874
|
+
{ type: "text", text: "out\n" },
|
|
875
|
+
{ type: "text", text: "warn" },
|
|
876
|
+
]);
|
|
877
|
+
expect(result.structuredContent).toBeUndefined();
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
test("buildToolCallSuccess stderr-only still includes stdout slot", () => {
|
|
881
|
+
const result = buildToolCallSuccess("", "warn\n");
|
|
882
|
+
expect(result.content).toEqual([
|
|
883
|
+
{ type: "text", text: "" },
|
|
884
|
+
{ type: "text", text: "warn" },
|
|
885
|
+
]);
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
test("buildToolCallSuccess parses JSON structuredContent", () => {
|
|
889
|
+
const result = buildToolCallSuccess('{"a":1}\n', "");
|
|
890
|
+
expect(result.structuredContent).toEqual({ a: 1 });
|
|
891
|
+
expect(result.content[0]!.text).toBe('{"a":1}\n');
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
test("buildToolCallSuccess skips structuredContent for plain text", () => {
|
|
895
|
+
const result = buildToolCallSuccess("lookup user=x\n", "");
|
|
896
|
+
expect(result.structuredContent).toBeUndefined();
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
test("buildToolCallSuccess parses JSON primitives", () => {
|
|
900
|
+
const result = buildToolCallSuccess("true\n", "");
|
|
901
|
+
expect(result.structuredContent).toBe(true);
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test("MCP initialize returns tools and resources capabilities", async () => {
|
|
905
|
+
const responses = await mcpRequest([{ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }]);
|
|
906
|
+
const res = responses.get(1) as { result: { capabilities: Record<string, unknown> } };
|
|
907
|
+
expect(res.result.capabilities.tools).toBeDefined();
|
|
908
|
+
expect(res.result.capabilities.resources).toBeDefined();
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
test("MCP tools/list includes stat_owner_lookup", async () => {
|
|
912
|
+
const responses = await mcpRequest([{ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }]);
|
|
913
|
+
const res = responses.get(2) as { result: { tools: { name: string; inputSchema: { required?: string[] } }[] } };
|
|
914
|
+
const lookup = res.result.tools.find((t) => t.name === "stat_owner_lookup");
|
|
915
|
+
expect(lookup).toBeDefined();
|
|
916
|
+
expect(lookup!.inputSchema.required).toContain("path");
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
test("MCP resources/read returns schema JSON", async () => {
|
|
920
|
+
const responses = await mcpRequest([
|
|
921
|
+
{ jsonrpc: "2.0", id: 3, method: "resources/read", params: { uri: "argsbarg://schema" } },
|
|
922
|
+
]);
|
|
923
|
+
const res = responses.get(3) as { result: { contents: { text: string }[] } };
|
|
924
|
+
const schema = JSON.parse(res.result.contents[0]!.text);
|
|
925
|
+
expect(schema.key).toBe("nested.ts");
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
test("MCP tools/call runs stat_owner_lookup", async () => {
|
|
929
|
+
const readme = join(import.meta.dir, "..", "README.md");
|
|
930
|
+
const responses = await mcpRequest([
|
|
931
|
+
{
|
|
932
|
+
jsonrpc: "2.0",
|
|
933
|
+
id: 4,
|
|
934
|
+
method: "tools/call",
|
|
935
|
+
params: {
|
|
936
|
+
name: "stat_owner_lookup",
|
|
937
|
+
arguments: { path: readme, "user-name": "test" },
|
|
938
|
+
},
|
|
939
|
+
},
|
|
940
|
+
]);
|
|
941
|
+
const res = responses.get(4) as { result: { content: { text: string }[]; isError: boolean } };
|
|
942
|
+
expect(res.result.isError).toBe(false);
|
|
943
|
+
expect(res.result.content[0]!.text).toContain("lookup user=test");
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
test("MCP tools/call returns structuredContent for JSON stdout", async () => {
|
|
947
|
+
const readme = join(import.meta.dir, "..", "README.md");
|
|
948
|
+
const responses = await mcpRequest([
|
|
949
|
+
{
|
|
950
|
+
jsonrpc: "2.0",
|
|
951
|
+
id: 6,
|
|
952
|
+
method: "tools/call",
|
|
953
|
+
params: {
|
|
954
|
+
name: "stat_owner_lookup",
|
|
955
|
+
arguments: { path: readme, "user-name": "test", json: true },
|
|
956
|
+
},
|
|
957
|
+
},
|
|
958
|
+
]);
|
|
959
|
+
const res = responses.get(6) as {
|
|
960
|
+
result: {
|
|
961
|
+
content: { text: string }[];
|
|
962
|
+
structuredContent?: { user: string; path: string };
|
|
963
|
+
isError: boolean;
|
|
964
|
+
};
|
|
965
|
+
};
|
|
966
|
+
expect(res.result.isError).toBe(false);
|
|
967
|
+
expect(res.result.structuredContent).toEqual({ user: "test", path: readme });
|
|
968
|
+
expect(JSON.parse(res.result.content[0]!.text.trim())).toEqual({ user: "test", path: readme });
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
test("MCP tools/call errors on missing required positional", async () => {
|
|
972
|
+
const responses = await mcpRequest([
|
|
973
|
+
{
|
|
974
|
+
jsonrpc: "2.0",
|
|
975
|
+
id: 5,
|
|
976
|
+
method: "tools/call",
|
|
977
|
+
params: { name: "stat_owner_lookup", arguments: { "user-name": "test" } },
|
|
978
|
+
},
|
|
979
|
+
]);
|
|
980
|
+
const res = responses.get(5) as { result: { isError: boolean; content: { text: string }[] } };
|
|
981
|
+
expect(res.result.isError).toBe(true);
|
|
982
|
+
expect(res.result.content[0]!.text).toContain("Missing argument: path");
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
test("MCP ping returns empty result", async () => {
|
|
986
|
+
const responses = await mcpRequest([{ jsonrpc: "2.0", id: 99, method: "ping", params: {} }]);
|
|
987
|
+
const res = responses.get(99) as { result: Record<string, never> };
|
|
988
|
+
expect(res.result).toEqual({});
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
test("minimal.ts mcp without opt-in fails", async () => {
|
|
992
|
+
const { stderr, exitCode } = await $`bun run examples/minimal.ts mcp`.nothrow().quiet();
|
|
993
|
+
expect(exitCode).toBe(1);
|
|
994
|
+
expect(stderr.toString()).toContain("mcp");
|
|
573
995
|
});
|
package/src/index.ts
CHANGED
|
@@ -10,5 +10,5 @@ module layout.
|
|
|
10
10
|
export { CliContext } from "./context.ts";
|
|
11
11
|
export { cliErrWithHelp, cliRun } from "./runtime";
|
|
12
12
|
export { CliFallbackMode, CliOptionKind, CliSchemaValidationError } from "./types.ts";
|
|
13
|
-
export type { CliCommand, CliHandler, CliOption, CliPositional } from "./types.ts";
|
|
13
|
+
export type { CliCommand, CliHandler, CliMcpServerConfig, CliMcpToolConfig, CliOption, CliPositional } from "./types.ts";
|
|
14
14
|
export { isInteractiveTty } from "./utils.ts";
|