@goke/mcp 0.0.9 → 0.0.10

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.
@@ -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: { name: string; caps: boolean }) => {
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: { left: number; right: number }) => ({
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: string, options: { repeat: number }) => {
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: { title: string; tag?: string; dryRun?: boolean }) => {
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: { name: string }) => `Hello ${options.name}!`);
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: { a: number; b: number }) => ({ sum: options.a + options.b }));
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: { query: string; limit: number }) => {
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: string, options: { dryRun: boolean }) => {
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: { key: string; value: string }) => {
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)