@goke/mcp 0.0.8 → 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.
@@ -5,8 +5,19 @@
5
5
  * or a high-level McpServer by mounting tools/list + tools/call handlers.
6
6
  */
7
7
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from "@modelcontextprotocol/sdk/types.js";
8
- import { coerceBySchema, extractJsonSchema } from "goke";
8
+ import { coerceBySchema, extractJsonSchema, GokeProcessExit, } from "goke";
9
9
  const CLI_TO_MCP_STATE = Symbol.for("@goke/mcp/cli-to-mcp-state");
10
+ function createTextCaptureStream() {
11
+ const chunks = [];
12
+ return {
13
+ get text() {
14
+ return chunks.join("");
15
+ },
16
+ write(data) {
17
+ chunks.push(data);
18
+ },
19
+ };
20
+ }
10
21
  function isMountableCommand(command, commandFilter) {
11
22
  if (!command.commandAction) {
12
23
  return false;
@@ -167,6 +178,90 @@ function isToolNotFoundResult(result, toolName) {
167
178
  const text = String(textBlock?.text ?? "").toLowerCase();
168
179
  return text.includes("tool") && text.includes("not found") && text.includes(toolName.toLowerCase());
169
180
  }
181
+ /**
182
+ * Build the same `GokeExecutionContext` an action would receive from
183
+ * `cli.parse()`, but with capture streams for stdout/stderr and an
184
+ * `exit` that throws `GokeProcessExit` instead of killing the host
185
+ * process.
186
+ *
187
+ * Capturing is required for the stdio MCP transport because the host
188
+ * `process.stdout` is the JSON-RPC channel — any write to it would
189
+ * corrupt the protocol. Capturing is also what lets us surface
190
+ * `ctx.console.log` output in the `CallToolResult.content`.
191
+ */
192
+ function createCallToolExecutionContext(cli) {
193
+ const stdout = createTextCaptureStream();
194
+ const stderr = createTextCaptureStream();
195
+ const ctx = cli.createExecutionContext({
196
+ stdout,
197
+ stderr,
198
+ // Swallow the user-level exit: the outer createExecutionContext
199
+ // wrapper will still throw `GokeProcessExit` after this returns,
200
+ // which `runCliTool` catches and turns into a `CallToolResult`.
201
+ exit: () => { },
202
+ });
203
+ return { ctx, stdout, stderr };
204
+ }
205
+ /**
206
+ * Build a `CallToolResult` from an action's return value plus any
207
+ * text that was captured from the injected `ctx.console` /
208
+ * `ctx.process.stdout` / `ctx.process.stderr` streams.
209
+ *
210
+ * Precedence rules:
211
+ * 1. If the action returned a ready-made `CallToolResult` (object
212
+ * with a `content` key), honor it as-is. Captured output is
213
+ * ignored to give authors a fully manual escape hatch.
214
+ * 2. If anything was captured on stdout or stderr, emit one text
215
+ * block per non-empty stream (stdout first, then stderr) and
216
+ * append the stringified return value as a trailing block when
217
+ * it is non-empty. This keeps warnings written via
218
+ * `ctx.console.error` / `ctx.process.stderr.write` from being
219
+ * silently dropped when the action also returns a value.
220
+ * 3. Otherwise fall back to the legacy behavior (stringify the
221
+ * return value, empty string when `undefined`).
222
+ */
223
+ function buildCallToolResult(returnValue, capturedStdout, capturedStderr) {
224
+ if (returnValue && typeof returnValue === "object" && "content" in returnValue) {
225
+ return returnValue;
226
+ }
227
+ if (capturedStdout || capturedStderr) {
228
+ const blocks = [];
229
+ if (capturedStdout) {
230
+ blocks.push({ type: "text", text: capturedStdout });
231
+ }
232
+ if (capturedStderr) {
233
+ blocks.push({ type: "text", text: capturedStderr });
234
+ }
235
+ const valueText = formatTextResult(returnValue);
236
+ if (valueText) {
237
+ blocks.push({ type: "text", text: valueText });
238
+ }
239
+ return { content: blocks };
240
+ }
241
+ return toCallToolResult(returnValue);
242
+ }
243
+ /**
244
+ * Build an error `CallToolResult` from captured output + the process
245
+ * exit code thrown by `ctx.process.exit(code)`. Mirrors the
246
+ * `{ stdout, stderr, exitCode }` shape just-bash produces, but in the
247
+ * MCP content-block format.
248
+ */
249
+ function buildProcessExitResult(exitCode, capturedStdout, capturedStderr) {
250
+ const content = [];
251
+ if (capturedStdout) {
252
+ content.push({ type: "text", text: capturedStdout });
253
+ }
254
+ if (capturedStderr) {
255
+ content.push({ type: "text", text: capturedStderr });
256
+ }
257
+ if (content.length === 0) {
258
+ content.push({ type: "text", text: `Process exited with code ${exitCode}` });
259
+ }
260
+ return {
261
+ isError: exitCode !== 0,
262
+ content,
263
+ };
264
+ }
170
265
  async function runCliTool(binding, argumentsObject) {
171
266
  for (const requiredName of binding.requiredNames) {
172
267
  if (getToolCallArguments(argumentsObject, requiredName) === undefined) {
@@ -212,19 +307,35 @@ async function runCliTool(binding, argumentsObject) {
212
307
  if (!action) {
213
308
  throw new McpError(ErrorCode.InvalidParams, `Command ${binding.command.name} has no action`);
214
309
  }
310
+ // Build the same execution context an action would see when invoked
311
+ // from the command line, but with capture streams + a no-op `exit`
312
+ // so tool calls can't corrupt the MCP transport or kill the host.
313
+ const { ctx, stdout, stderr } = createCallToolExecutionContext(binding.cli);
215
314
  try {
216
- const result = await Promise.resolve(action(...positionalValues, optionsObject));
217
- return toCallToolResult(result);
315
+ // Match `Goke#runMatchedCommand` by calling the action with the
316
+ // owning cli as `this`. Keeps behavior parity for JS authors who
317
+ // reference `this.name` / `this.options` from inside an action.
318
+ const result = await Promise.resolve(action.apply(binding.cli, [...positionalValues, optionsObject, ctx]));
319
+ return buildCallToolResult(result, stdout.text, stderr.text);
218
320
  }
219
321
  catch (error) {
322
+ if (error instanceof GokeProcessExit) {
323
+ return buildProcessExitResult(error.code, stdout.text, stderr.text);
324
+ }
220
325
  const message = error instanceof Error ? error.message : String(error);
326
+ const content = [
327
+ { type: "text", text: message },
328
+ ];
329
+ if (stderr.text) {
330
+ content.push({ type: "text", text: stderr.text });
331
+ }
221
332
  return {
222
333
  isError: true,
223
- content: [{ type: "text", text: message }],
334
+ content,
224
335
  };
225
336
  }
226
337
  }
227
- function createBinding(command, toolName) {
338
+ function createBinding(cli, command, toolName) {
228
339
  const positionalArgs = command.args;
229
340
  const options = command.options;
230
341
  const properties = {};
@@ -272,6 +383,7 @@ function createBinding(command, toolName) {
272
383
  inputSchema,
273
384
  },
274
385
  command,
386
+ cli,
275
387
  positionalArgs,
276
388
  options: optionBindings,
277
389
  requiredNames: Array.from(new Set(requiredNames)),
@@ -342,6 +454,50 @@ function getOrInstallState(server) {
342
454
  });
343
455
  return state;
344
456
  }
457
+ /**
458
+ * Create a goke action callback that starts an MCP server over stdio.
459
+ *
460
+ * Exposes all CLI commands as MCP tools, automatically excluding the
461
+ * command this action is attached to.
462
+ *
463
+ * @example
464
+ * ```ts
465
+ * cli.command('mcp', 'Start MCP server over stdio')
466
+ * .action(createMcpAction({ cli }))
467
+ * ```
468
+ */
469
+ export function createMcpAction(options) {
470
+ const { cli, commandFilter: userFilter, sanitizeToolName, serverName, serverVersion, createTransport } = options;
471
+ return async () => {
472
+ // At call time, goke has already matched the command and set matchedCommandName.
473
+ // We use it to auto-exclude the MCP command itself from the tool list.
474
+ const mcpCommandName = cli.matchedCommandName;
475
+ const { Server: ServerClass } = await import("@modelcontextprotocol/sdk/server/index.js");
476
+ const server = new ServerClass({
477
+ name: serverName || cli.name || "cli-mcp-server",
478
+ version: serverVersion || "1.0.0",
479
+ }, { capabilities: {} });
480
+ addCliToolsToMcp({
481
+ cli,
482
+ server,
483
+ commandFilter: (name) => {
484
+ if (mcpCommandName && name === mcpCommandName)
485
+ return false;
486
+ return userFilter ? userFilter(name) : true;
487
+ },
488
+ sanitizeToolName,
489
+ });
490
+ let transport;
491
+ if (createTransport) {
492
+ transport = await createTransport();
493
+ }
494
+ else {
495
+ const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
496
+ transport = new StdioServerTransport();
497
+ }
498
+ await server.connect(transport);
499
+ };
500
+ }
345
501
  export function addCliToolsToMcp(options) {
346
502
  const { cli, commandFilter, sanitizeToolName = defaultSanitizeToolName } = options;
347
503
  const server = resolveServer(options.server);
@@ -373,7 +529,7 @@ export function addCliToolsToMcp(options) {
373
529
  const baseToolName = defaultSanitizeToolName(sanitizeToolName(command.name));
374
530
  const toolName = uniqueToolName(baseToolName, usedNames);
375
531
  usedNames.add(toolName);
376
- const binding = createBinding(command, toolName);
532
+ const binding = createBinding(cli, command, toolName);
377
533
  state.toolsByName.set(toolName, binding);
378
534
  state.commandToToolName.set(command.name, toolName);
379
535
  }
package/dist/index.d.ts CHANGED
@@ -42,8 +42,8 @@
42
42
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
43
43
  import type { Goke } from "goke";
44
44
  import type { McpOAuthConfig } from "./types.js";
45
- export { addCliToolsToMcp } from "./cli-to-mcp.js";
46
- export type { AddCliToolsToMcpOptions } from "./cli-to-mcp.js";
45
+ export { addCliToolsToMcp, createMcpAction } from "./cli-to-mcp.js";
46
+ export type { AddCliToolsToMcpOptions, CreateMcpActionOptions } from "./cli-to-mcp.js";
47
47
  export type { Transport };
48
48
  export type { McpOAuthConfig, McpOAuthState } from "./types.js";
49
49
  export interface CachedMcpTools {
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+CAA+C,CAAC;AAC/E,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAKjC,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,YAAY,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACnD,YAAY,EAAE,uBAAuB,EAAE,MAAM,iBAAiB,CAAC;AAG/D,YAAY,EAAE,SAAS,EAAE,CAAC;AAC1B,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhE,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,KAAK,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,WAAW,CAAC,EAAE,OAAO,CAAC;KACvB,CAAC,CAAC;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAID,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,IAAI,CAAC;IACV;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAErC;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,SAAS,CAAC,EAAE,MAAM,KAAK,SAAS,GAAG,IAAI,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC;IAEvF;;;;;;;;;;;;;;OAcG;IACH,KAAK,CAAC,EAAE,cAAc,CAAC;IAEvB;;OAEG;IACH,SAAS,EAAE,MAAM,cAAc,GAAG,SAAS,CAAC;IAE5C;;OAEG;IACH,SAAS,EAAE,CAAC,KAAK,EAAE,cAAc,GAAG,SAAS,KAAK,IAAI,CAAC;CACxD;AAmID;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyMlF"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+CAA+C,CAAC;AAC/E,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAKjC,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,YAAY,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACpE,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAC;AAGvF,YAAY,EAAE,SAAS,EAAE,CAAC;AAC1B,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhE,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,KAAK,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,WAAW,CAAC,EAAE,OAAO,CAAC;KACvB,CAAC,CAAC;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAID,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,IAAI,CAAC;IACV;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAErC;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,SAAS,CAAC,EAAE,MAAM,KAAK,SAAS,GAAG,IAAI,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC;IAEvF;;;;;;;;;;;;;;OAcG;IACH,KAAK,CAAC,EAAE,cAAc,CAAC;IAEvB;;OAEG;IACH,SAAS,EAAE,MAAM,cAAc,GAAG,SAAS,CAAC;IAE5C;;OAEG;IACH,SAAS,EAAE,CAAC,KAAK,EAAE,cAAc,GAAG,SAAS,KAAK,IAAI,CAAC;CACxD;AAmID;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyMlF"}
package/dist/index.js CHANGED
@@ -45,7 +45,7 @@ import { wrapJsonSchema } from "goke";
45
45
  import yaml from "js-yaml";
46
46
  import { FileOAuthProvider } from "./oauth-provider.js";
47
47
  import { startOAuthFlow, isAuthRequiredError } from "./auth.js";
48
- export { addCliToolsToMcp } from "./cli-to-mcp.js";
48
+ export { addCliToolsToMcp, createMcpAction } from "./cli-to-mcp.js";
49
49
  const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
50
50
  /**
51
51
  * Check if a schema represents a complex type (object/array) for help text display.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goke/mcp",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "type": "module",
5
5
  "description": "Dynamically generate CLI commands from MCP server tools",
6
6
  "repository": {
@@ -51,7 +51,7 @@
51
51
  "@types/node": "^22.19.7",
52
52
  "vitest": "^3.1.0",
53
53
  "zod": "^4.3.6",
54
- "goke": "^6.2.3"
54
+ "goke": "^6.8.0"
55
55
  },
56
56
  "scripts": {
57
57
  "clean": "rm -rf dist",
@@ -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
+ });