@goke/mcp 0.0.9 → 0.0.11
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/README.md +214 -0
- package/dist/__test__/add-cli-tools-to-mcp.test.js +223 -2
- package/dist/__test__/create-mcp-action.test.js +4 -2
- package/dist/__test__/http-multi-tenant.test.d.ts +28 -0
- package/dist/__test__/http-multi-tenant.test.d.ts.map +1 -0
- package/dist/__test__/http-multi-tenant.test.js +309 -0
- package/dist/auth.d.ts +4 -2
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +4 -2
- package/dist/cli-to-mcp.d.ts.map +1 -1
- package/dist/cli-to-mcp.js +118 -6
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/package.json +2 -2
- package/src/__test__/add-cli-tools-to-mcp.test.ts +306 -7
- package/src/__test__/create-mcp-action.test.ts +9 -7
- package/src/__test__/http-multi-tenant.test.ts +383 -0
- package/src/auth.ts +4 -2
- package/src/cli-to-mcp.ts +168 -6
- package/src/index.ts +3 -2
|
@@ -7,7 +7,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
|
7
7
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
8
8
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
9
|
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
-
import { goke, wrapJsonSchema } from "goke";
|
|
10
|
+
import { goke, wrapJsonSchema, type Goke } from "goke";
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
import { describe, expect, it } from "vitest";
|
|
13
13
|
import { addCliToolsToMcp } from "../cli-to-mcp.js";
|
|
@@ -19,11 +19,13 @@ function createCli() {
|
|
|
19
19
|
.command("say hi", "Say hello")
|
|
20
20
|
.option("--name <name>", z.string().describe("Person to greet"))
|
|
21
21
|
.option("--caps", z.boolean().default(false).describe("Uppercase output"))
|
|
22
|
-
.action((options
|
|
22
|
+
.action((options) => {
|
|
23
23
|
const message = `Hello ${options.name}!`;
|
|
24
24
|
return options.caps ? message.toUpperCase() : message;
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
+
// sum-values uses wrapJsonSchema whose output is `unknown`, so values are
|
|
28
|
+
// cast with Number() inside the action.
|
|
27
29
|
cli
|
|
28
30
|
.command("sum-values", "Add two numbers")
|
|
29
31
|
.option(
|
|
@@ -40,8 +42,8 @@ function createCli() {
|
|
|
40
42
|
description: "Right operand",
|
|
41
43
|
}),
|
|
42
44
|
)
|
|
43
|
-
.action((options
|
|
44
|
-
sum: options.left + options.right,
|
|
45
|
+
.action((options) => ({
|
|
46
|
+
sum: Number(options.left) + Number(options.right),
|
|
45
47
|
}));
|
|
46
48
|
|
|
47
49
|
cli
|
|
@@ -54,8 +56,8 @@ function createCli() {
|
|
|
54
56
|
description: "Repeat count",
|
|
55
57
|
}),
|
|
56
58
|
)
|
|
57
|
-
.action((message
|
|
58
|
-
return message.repeat(options.repeat);
|
|
59
|
+
.action((message, options) => {
|
|
60
|
+
return message.repeat(Number(options.repeat));
|
|
59
61
|
});
|
|
60
62
|
|
|
61
63
|
cli
|
|
@@ -63,7 +65,7 @@ function createCli() {
|
|
|
63
65
|
.option("--title <title>", "Required title")
|
|
64
66
|
.option("--tag [tag]", "Optional tag")
|
|
65
67
|
.option("--dry-run", "Dry run flag")
|
|
66
|
-
.action((options
|
|
68
|
+
.action((options) => {
|
|
67
69
|
return options;
|
|
68
70
|
});
|
|
69
71
|
|
|
@@ -457,3 +459,300 @@ describe("addCliToolsToMcp", () => {
|
|
|
457
459
|
}
|
|
458
460
|
});
|
|
459
461
|
});
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Spin up a live MCP client/server pair wired to a single cli.
|
|
465
|
+
*
|
|
466
|
+
* Used by the execution-context tests below to keep the boilerplate
|
|
467
|
+
* out of each test body.
|
|
468
|
+
*/
|
|
469
|
+
async function withMcpClient<T>(
|
|
470
|
+
cli: Goke,
|
|
471
|
+
fn: (client: Client) => Promise<T>,
|
|
472
|
+
): Promise<T> {
|
|
473
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
474
|
+
const server = new Server({ name: "test-server", version: "1.0.0" }, { capabilities: {} });
|
|
475
|
+
addCliToolsToMcp({ cli, server });
|
|
476
|
+
|
|
477
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
478
|
+
try {
|
|
479
|
+
await server.connect(serverTransport);
|
|
480
|
+
await client.connect(clientTransport);
|
|
481
|
+
return await fn(client);
|
|
482
|
+
} finally {
|
|
483
|
+
await client.close();
|
|
484
|
+
await server.close();
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function textBlocks(result: Awaited<ReturnType<Client["callTool"]>>): string[] {
|
|
489
|
+
const content = "content" in result ? (result as { content: Array<{ type: string; text?: string }> }).content : [];
|
|
490
|
+
return content.filter((entry) => entry.type === "text").map((entry) => entry.text ?? "");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
describe("addCliToolsToMcp execution context", () => {
|
|
494
|
+
it("passes an execution context as the third argument to the action", async () => {
|
|
495
|
+
const cli = goke("ctx-cli", {
|
|
496
|
+
cwd: "/workspace",
|
|
497
|
+
env: { TOKEN: "abc", USER: "tommy" },
|
|
498
|
+
stdin: "hello from stdin",
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
cli.command("inspect-ctx", "Return the injected execution context").action((_options, ctx) => {
|
|
502
|
+
return {
|
|
503
|
+
hasCtx: ctx != null,
|
|
504
|
+
hasConsole: typeof ctx?.console?.log === "function",
|
|
505
|
+
hasFs: typeof ctx?.fs?.readFile === "function",
|
|
506
|
+
cwd: ctx?.process?.cwd,
|
|
507
|
+
token: ctx?.process?.env?.TOKEN,
|
|
508
|
+
user: ctx?.process?.env?.USER,
|
|
509
|
+
stdin: ctx?.process?.stdin,
|
|
510
|
+
};
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
const result = await withMcpClient(cli, (client) =>
|
|
514
|
+
client.callTool({ name: "inspect-ctx", arguments: {} }),
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
expect(firstTextContent(result)).toMatchInlineSnapshot(`
|
|
518
|
+
"{
|
|
519
|
+
"hasCtx": true,
|
|
520
|
+
"hasConsole": true,
|
|
521
|
+
"hasFs": true,
|
|
522
|
+
"cwd": "/workspace",
|
|
523
|
+
"token": "abc",
|
|
524
|
+
"user": "tommy",
|
|
525
|
+
"stdin": "hello from stdin"
|
|
526
|
+
}"
|
|
527
|
+
`);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("captures ctx.console.log output into the tool result content", async () => {
|
|
531
|
+
const cli = goke("logs-cli");
|
|
532
|
+
|
|
533
|
+
cli.command("noisy", "Write to ctx.console and return nothing").action((_options, ctx) => {
|
|
534
|
+
ctx.console.log("line one");
|
|
535
|
+
ctx.console.log("line", "two");
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
const result = await withMcpClient(cli, (client) =>
|
|
539
|
+
client.callTool({ name: "noisy", arguments: {} }),
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
expect(textBlocks(result)).toMatchInlineSnapshot(`
|
|
543
|
+
[
|
|
544
|
+
"line one
|
|
545
|
+
line two
|
|
546
|
+
",
|
|
547
|
+
]
|
|
548
|
+
`);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("captures ctx.console.log output and still uses the action's return value", async () => {
|
|
552
|
+
const cli = goke("logs-plus-return-cli");
|
|
553
|
+
|
|
554
|
+
cli.command("both", "Log and return").action((_options, ctx) => {
|
|
555
|
+
ctx.console.log("before");
|
|
556
|
+
return "the-return-value";
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
const result = await withMcpClient(cli, (client) =>
|
|
560
|
+
client.callTool({ name: "both", arguments: {} }),
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
// Captured stdout first, then the stringified return value, as
|
|
564
|
+
// separate content blocks. Authors who want a single block can
|
|
565
|
+
// return a `{ content }` object to bypass this merging.
|
|
566
|
+
expect(textBlocks(result)).toMatchInlineSnapshot(`
|
|
567
|
+
[
|
|
568
|
+
"before
|
|
569
|
+
",
|
|
570
|
+
"the-return-value",
|
|
571
|
+
]
|
|
572
|
+
`);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("treats ctx.process.exit(0) as a success result with captured content", async () => {
|
|
576
|
+
const cli = goke("exit-ok-cli");
|
|
577
|
+
|
|
578
|
+
cli.command("exit-ok", "Exit cleanly").action((_options, ctx) => {
|
|
579
|
+
ctx.console.log("all good");
|
|
580
|
+
ctx.process.exit(0);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
const result = await withMcpClient(cli, (client) =>
|
|
584
|
+
client.callTool({ name: "exit-ok", arguments: {} }),
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
expect(result.isError).toBeFalsy();
|
|
588
|
+
expect(textBlocks(result)).toMatchInlineSnapshot(`
|
|
589
|
+
[
|
|
590
|
+
"all good
|
|
591
|
+
",
|
|
592
|
+
]
|
|
593
|
+
`);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("treats ctx.process.exit(1) as an isError result with captured stderr", async () => {
|
|
597
|
+
const cli = goke("exit-fail-cli");
|
|
598
|
+
|
|
599
|
+
cli.command("exit-fail", "Exit with error").action((_options, ctx) => {
|
|
600
|
+
ctx.console.error("boom");
|
|
601
|
+
ctx.process.exit(1);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
const result = await withMcpClient(cli, (client) =>
|
|
605
|
+
client.callTool({ name: "exit-fail", arguments: {} }),
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
expect(result.isError).toBe(true);
|
|
609
|
+
expect(textBlocks(result)).toMatchInlineSnapshot(`
|
|
610
|
+
[
|
|
611
|
+
"boom
|
|
612
|
+
",
|
|
613
|
+
]
|
|
614
|
+
`);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("does not corrupt the MCP transport when the action writes to ctx.process.stdout directly", async () => {
|
|
618
|
+
const cli = goke("stdout-cli");
|
|
619
|
+
|
|
620
|
+
cli.command("write-stdout", "Write through ctx.process.stdout").action((_options, ctx) => {
|
|
621
|
+
ctx.process.stdout.write("from-process-stdout\n");
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
const result = await withMcpClient(cli, (client) =>
|
|
625
|
+
client.callTool({ name: "write-stdout", arguments: {} }),
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
expect(firstTextContent(result)).toBe("from-process-stdout\n");
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("keeps the server alive after a tool action calls ctx.process.exit", async () => {
|
|
632
|
+
const cli = goke("survive-cli");
|
|
633
|
+
|
|
634
|
+
cli.command("boom", "Exit with non-zero code").action((_options, ctx) => {
|
|
635
|
+
ctx.process.exit(2);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
cli.command("ping", "Return a value").action(() => "pong");
|
|
639
|
+
|
|
640
|
+
await withMcpClient(cli, async (client) => {
|
|
641
|
+
const boomResult = await client.callTool({ name: "boom", arguments: {} });
|
|
642
|
+
expect(boomResult.isError).toBe(true);
|
|
643
|
+
|
|
644
|
+
// Server must still be able to serve subsequent tool calls.
|
|
645
|
+
const pingResult = await client.callTool({ name: "ping", arguments: {} });
|
|
646
|
+
expect(firstTextContent(pingResult)).toBe("pong");
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it("does not include captured content when the action returns a ready-made CallToolResult", async () => {
|
|
651
|
+
const cli = goke("raw-cli");
|
|
652
|
+
|
|
653
|
+
cli.command("raw", "Return a raw CallToolResult").action((_options, ctx) => {
|
|
654
|
+
// This write should be ignored — returning a {content} object is
|
|
655
|
+
// the explicit escape hatch for authors who want full control.
|
|
656
|
+
ctx.console.log("ignored-capture");
|
|
657
|
+
return {
|
|
658
|
+
content: [
|
|
659
|
+
{ type: "text" as const, text: "authoritative" },
|
|
660
|
+
],
|
|
661
|
+
};
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
const result = await withMcpClient(cli, (client) =>
|
|
665
|
+
client.callTool({ name: "raw", arguments: {} }),
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
expect(textBlocks(result)).toEqual(["authoritative"]);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("captures ctx.console.error output on the success path", async () => {
|
|
672
|
+
const cli = goke("success-stderr-cli");
|
|
673
|
+
|
|
674
|
+
cli.command("warn-and-return", "Emit a warning and return a value").action((_options, ctx) => {
|
|
675
|
+
ctx.console.error("something suspicious");
|
|
676
|
+
return { ok: true };
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
const result = await withMcpClient(cli, (client) =>
|
|
680
|
+
client.callTool({ name: "warn-and-return", arguments: {} }),
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
// Captured stderr lands in its own text block so authors can spot
|
|
684
|
+
// the warning even though the action returned successfully. The
|
|
685
|
+
// stringified return value is appended after it.
|
|
686
|
+
expect(result.isError).toBeFalsy();
|
|
687
|
+
expect(textBlocks(result)).toMatchInlineSnapshot(`
|
|
688
|
+
[
|
|
689
|
+
"something suspicious
|
|
690
|
+
",
|
|
691
|
+
"{
|
|
692
|
+
"ok": true
|
|
693
|
+
}",
|
|
694
|
+
]
|
|
695
|
+
`);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("captures ctx.process.stderr.write output on the success path", async () => {
|
|
699
|
+
const cli = goke("success-stderr-write-cli");
|
|
700
|
+
|
|
701
|
+
cli.command("warn-only", "Write to stderr and return undefined").action((_options, ctx) => {
|
|
702
|
+
ctx.process.stderr.write("low-level-warning\n");
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
const result = await withMcpClient(cli, (client) =>
|
|
706
|
+
client.callTool({ name: "warn-only", arguments: {} }),
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
expect(result.isError).toBeFalsy();
|
|
710
|
+
expect(textBlocks(result)).toEqual(["low-level-warning\n"]);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("does not leak tool output into the cli's configured stdout/stderr", async () => {
|
|
714
|
+
const sentinelStdout: string[] = [];
|
|
715
|
+
const sentinelStderr: string[] = [];
|
|
716
|
+
const cli = goke("sentinel-cli", {
|
|
717
|
+
stdout: { write: (data) => { sentinelStdout.push(data); } },
|
|
718
|
+
stderr: { write: (data) => { sentinelStderr.push(data); } },
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
cli.command("noisy", "Write to both streams").action((_options, ctx) => {
|
|
722
|
+
ctx.console.log("stdout-chatter");
|
|
723
|
+
ctx.console.error("stderr-chatter");
|
|
724
|
+
ctx.process.stdout.write("direct-stdout\n");
|
|
725
|
+
ctx.process.stderr.write("direct-stderr\n");
|
|
726
|
+
return "value";
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
const result = await withMcpClient(cli, (client) =>
|
|
730
|
+
client.callTool({ name: "noisy", arguments: {} }),
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
// Everything lands in the CallToolResult — the cli's configured
|
|
734
|
+
// host streams must not receive a single byte during a tool call.
|
|
735
|
+
expect(sentinelStdout.join("")).toBe("");
|
|
736
|
+
expect(sentinelStderr.join("")).toBe("");
|
|
737
|
+
expect(textBlocks(result).join("|")).toBe(
|
|
738
|
+
"stdout-chatter\ndirect-stdout\n|stderr-chatter\ndirect-stderr\n|value",
|
|
739
|
+
);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it("invokes command actions with the owning cli as `this`", async () => {
|
|
743
|
+
const cli = goke("this-binding-cli");
|
|
744
|
+
|
|
745
|
+
let seenThis: unknown;
|
|
746
|
+
cli.command("whoami", "Report this-binding").action(function (this: unknown, _options, _ctx) {
|
|
747
|
+
seenThis = this;
|
|
748
|
+
return "ok";
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
await withMcpClient(cli, (client) =>
|
|
752
|
+
client.callTool({ name: "whoami", arguments: {} }),
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
// Same binding Goke#runMatchedCommand uses for parse-path actions.
|
|
756
|
+
expect(seenThis).toBe(cli);
|
|
757
|
+
});
|
|
758
|
+
});
|
|
@@ -30,13 +30,13 @@ describe("createMcpAction", () => {
|
|
|
30
30
|
cli
|
|
31
31
|
.command("greet", "Say hello")
|
|
32
32
|
.option("--name <name>", z.string().describe("Person to greet"))
|
|
33
|
-
.action((options
|
|
33
|
+
.action((options) => `Hello ${options.name}!`);
|
|
34
34
|
|
|
35
35
|
cli
|
|
36
36
|
.command("add", "Add numbers")
|
|
37
37
|
.option("--a <a>", z.number().describe("First"))
|
|
38
38
|
.option("--b <b>", z.number().describe("Second"))
|
|
39
|
-
.action((options
|
|
39
|
+
.action((options) => ({ sum: options.a + options.b }));
|
|
40
40
|
|
|
41
41
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
42
42
|
|
|
@@ -163,7 +163,7 @@ describe("createMcpAction", () => {
|
|
|
163
163
|
.command("search", "Search for items")
|
|
164
164
|
.option("--query <query>", z.string().describe("Search query"))
|
|
165
165
|
.option("--limit [limit]", z.number().default(10).describe("Max results"))
|
|
166
|
-
.action((options
|
|
166
|
+
.action((options) => {
|
|
167
167
|
return { results: [`result for "${options.query}"`], limit: options.limit };
|
|
168
168
|
});
|
|
169
169
|
|
|
@@ -171,7 +171,7 @@ describe("createMcpAction", () => {
|
|
|
171
171
|
cli
|
|
172
172
|
.command("deploy <env>", "Deploy to environment")
|
|
173
173
|
.option("--dry-run", z.boolean().default(false).describe("Simulate deployment"))
|
|
174
|
-
.action((env
|
|
174
|
+
.action((env, options) => {
|
|
175
175
|
return options.dryRun ? `dry-run deploy to ${env}` : `deployed to ${env}`;
|
|
176
176
|
});
|
|
177
177
|
|
|
@@ -189,13 +189,15 @@ describe("createMcpAction", () => {
|
|
|
189
189
|
throw new Error("something went wrong");
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
-
// Wrapped JSON schema command
|
|
192
|
+
// Wrapped JSON schema command.
|
|
193
|
+
// wrapJsonSchema produces a StandardJSONSchemaV1 with `unknown` output, so
|
|
194
|
+
// values are cast explicitly inside the action.
|
|
193
195
|
cli
|
|
194
196
|
.command("config set", "Set a config value")
|
|
195
197
|
.option("--key <key>", wrapJsonSchema({ type: "string", description: "Config key" }))
|
|
196
198
|
.option("--value <value>", wrapJsonSchema({ type: "string", description: "Config value" }))
|
|
197
|
-
.action((options
|
|
198
|
-
return `set ${options.key} = ${options.value}`;
|
|
199
|
+
.action((options) => {
|
|
200
|
+
return `set ${String(options.key)} = ${String(options.value)}`;
|
|
199
201
|
});
|
|
200
202
|
|
|
201
203
|
// Commands without actions (should NOT appear as tools)
|