@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.
package/README.md CHANGED
@@ -62,62 +62,327 @@ notion-mcp-cli notion-retrieve-page --page_id "abc123"
62
62
  notion-mcp-cli notion-list-users
63
63
  ```
64
64
 
65
- ## Turn a goke CLI into an MCP server
65
+ ## Expose a CLI as an MCP server
66
66
 
67
- `addCliToolsToMcp()` does the inverse mapping: every CLI command becomes an MCP tool.
68
-
69
- - Command description → MCP tool description
70
- - Option schema (Zod or any Standard Schema library) → MCP `inputSchema` JSON Schema
71
- - Command names are sanitized into valid MCP tool names (invalid characters become `_`)
72
- - Composable with existing MCP tools already registered on the same server
73
-
74
- ### With low-level `Server`
67
+ `createMcpAction()` turns your entire CLI into a stdio MCP server with one line. Every CLI command becomes an MCP tool automatically. The command you attach it to is excluded from the tool list.
75
68
 
76
69
  ```ts
77
70
  import { goke } from "goke"
78
71
  import { z } from "zod"
79
- import { Server } from "@modelcontextprotocol/sdk/server/index.js"
80
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
81
- import { addCliToolsToMcp } from "@goke/mcp"
72
+ import { createMcpAction } from "@goke/mcp"
82
73
 
83
74
  const cli = goke("my-cli")
84
75
 
85
76
  cli
86
- .command("notion search", "Search Notion pages")
77
+ .command("search", "Search pages")
87
78
  .option("--query <query>", z.string().describe("Search query"))
88
- .action((options) => ({ query: options.query }))
79
+ .option("--limit [limit]", z.number().default(10).describe("Max results"))
80
+ .action((options) => {
81
+ return { results: findPages(options.query, options.limit) }
82
+ })
83
+
84
+ cli
85
+ .command("deploy <env>", "Deploy to environment")
86
+ .option("--dry-run", z.boolean().default(false).describe("Simulate"))
87
+ .action((env, options) => {
88
+ return options.dryRun ? `would deploy to ${env}` : deploy(env)
89
+ })
90
+
91
+ // Add MCP support — runs a stdio MCP server when the user invokes `my-cli mcp`
92
+ cli.command("mcp", "Start MCP server over stdio")
93
+ .action(createMcpAction({ cli }))
94
+
95
+ cli.help()
96
+ cli.parse()
97
+ ```
98
+
99
+ Now users can use your CLI directly **or** connect it as an MCP server:
100
+
101
+ ```bash
102
+ # Use as a normal CLI
103
+ my-cli search --query "meeting notes"
104
+ my-cli deploy staging --dry-run
105
+
106
+ # Use as an MCP server (e.g. from Claude Desktop, Cursor, etc.)
107
+ my-cli mcp
108
+ ```
109
+
110
+ When running as MCP, the server exposes `search` and `deploy` as tools. The `mcp` command itself is excluded. Options with Zod schemas (or any Standard Schema) become typed `inputSchema` properties in the MCP tool definition.
111
+
112
+ ### Installing the MCP server in clients
113
+
114
+ Users can install your CLI as an MCP server in any client using [`@playwriter/install-mcp`](https://github.com/nicepkg/install-mcp) — a cross-platform tool that handles config file locations for every major MCP client:
115
+
116
+ ```bash
117
+ # Install in Claude Desktop
118
+ npx @playwriter/install-mcp my-cli --client claude-desktop
119
+
120
+ # Install in Cursor
121
+ npx @playwriter/install-mcp my-cli --client cursor
122
+
123
+ # Install in VS Code
124
+ npx @playwriter/install-mcp my-cli --client vscode
125
+ ```
126
+
127
+ This works with any client: `claude-desktop`, `cursor`, `vscode`, `windsurf`, `claude-code`, `opencode`, `zed`, `goose`, `cline`, `codex`, `gemini-cli`, and [more](https://github.com/supermemoryai/install-mcp#supported-clients). If the command needs custom arguments, pass the full command string:
128
+
129
+ ```bash
130
+ npx @playwriter/install-mcp 'npx my-cli mcp' --client cursor
131
+ ```
132
+
133
+ `createMcpAction` accepts the same filtering options as `addCliToolsToMcp`:
134
+
135
+ | Option | Type | Default | Description |
136
+ |--------|------|---------|-------------|
137
+ | `cli` | `Goke` | **required** | The CLI instance to expose |
138
+ | `commandFilter` | `(name) => boolean` | — | Additional filter (MCP command is always excluded) |
139
+ | `sanitizeToolName` | `(name) => string` | — | Custom tool name sanitizer |
140
+ | `serverName` | `string` | CLI name | MCP server name |
141
+ | `serverVersion` | `string` | `'1.0.0'` | MCP server version |
142
+ | `createTransport` | `() => Transport` | stdio | Custom transport factory |
143
+
144
+ ### Advanced: `addCliToolsToMcp`
145
+
146
+ For more control (composing with existing MCP tools, using a custom server), use `addCliToolsToMcp()` directly:
147
+
148
+ ```ts
149
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js"
150
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
151
+ import { addCliToolsToMcp } from "@goke/mcp"
89
152
 
90
153
  const server = new Server(
91
154
  { name: "my-cli-mcp", version: "1.0.0" },
92
155
  { capabilities: {} },
93
156
  )
94
157
 
158
+ // Mount CLI commands as tools alongside your own
95
159
  addCliToolsToMcp({ cli, server })
96
160
 
97
161
  const transport = new StdioServerTransport()
98
162
  await server.connect(transport)
99
163
  ```
100
164
 
101
- Run it with Node:
165
+ Also works with the high-level `McpServer`:
102
166
 
103
- ```bash
104
- node dist/server.js
167
+ ```ts
168
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
169
+
170
+ const mcp = new McpServer({ name: "my-cli-mcp", version: "1.0.0" })
171
+ mcp.tool("custom-tool", "A tool defined directly", async () => ({ ... }))
172
+ addCliToolsToMcp({ cli, server: mcp })
105
173
  ```
106
174
 
107
- ### With high-level `McpServer`
175
+ ## Multi-tenant remote MCP over HTTP
176
+
177
+ When you expose a cli as a **remote** MCP (over `StreamableHTTPServerTransport`, SSE, or any other network transport), one server process handles many concurrent users. Each tool call must run against that user's own filesystem, working directory, environment, and stdin — otherwise tenants see each other's state and stdio writes trample the JSON-RPC channel.
178
+
179
+ The recipe is:
180
+
181
+ 1. Define the cli **once**.
182
+ 2. On every new MCP session, **clone** the cli with per-tenant `{ cwd, env, fs, stdin }` and mount it on a fresh session-scoped `Server` via `addCliToolsToMcp({ cli: tenantClone, server })`.
183
+ 3. Inside command actions, always use the **injected** `ctx` — `ctx.fs`, `ctx.process.cwd`, `ctx.process.env`, `ctx.console.log` — instead of the Node globals. `@goke/mcp` wires each tool call into the tenant's cloned context, but only code that goes through `ctx` participates in that isolation.
184
+
185
+ ### Write commands against `ctx`
108
186
 
109
187
  ```ts
110
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
111
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
188
+ import { goke } from "goke"
189
+ import { z } from "zod"
190
+ import path from "node:path"
191
+
192
+ const cli = goke("notes-app")
193
+
194
+ cli
195
+ .command("save <filename>", "Save content into the user's workspace")
196
+ .option("--content <content>", z.string().describe("File content"))
197
+ .action(async (filename, options, ctx) => {
198
+ const full = path.posix.join(ctx.process.cwd, filename)
199
+ await ctx.fs.writeFile(full, options.content)
200
+ return { saved: full, tenant: ctx.process.env.TENANT_ID }
201
+ })
202
+
203
+ cli
204
+ .command("load <filename>", "Load a file from the user's workspace")
205
+ .action(async (filename, _options, ctx) => {
206
+ const full = path.posix.join(ctx.process.cwd, filename)
207
+ const text = await ctx.fs.readFile(full, "utf8")
208
+ return { path: full, text }
209
+ })
210
+ ```
211
+
212
+ `ctx.fs` satisfies the `GokeFs` interface — a Node-compatible async filesystem API. You can point it at a real directory, a virtual in-memory store, an S3 bucket adapter, a `memfs`, or anything else you can wrap behind that interface.
213
+
214
+ ### Clone the cli per session
215
+
216
+ The MCP SDK ships `WebStandardStreamableHTTPServerTransport`, which accepts a Web-Standard `Request` and returns a `Response`. That one shape plugs directly into **any** web framework that speaks web-standard: [Spiceflow](https://github.com/remorses/spiceflow), Cloudflare Workers, Deno, Bun, Next.js route handlers, SvelteKit endpoints, or a raw `fetch`-based handler. No Express, no `node:http` wiring, no framework lock-in.
217
+
218
+ You build one `handleMcpRequest(request: Request): Promise<Response>` function and mount it wherever you route HTTP:
219
+
220
+ ```ts
221
+ import { randomUUID } from "node:crypto"
222
+ import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js"
223
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
224
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"
112
225
  import { addCliToolsToMcp } from "@goke/mcp"
226
+ import type { GokeFs } from "goke"
113
227
 
114
- const mcp = new McpServer({ name: "my-cli-mcp", version: "1.0.0" })
115
- addCliToolsToMcp({ cli, server: mcp })
228
+ // Wherever you store per-user state — DB, Redis, config files, etc.
229
+ declare function resolveTenant(tenantId: string): {
230
+ cwd: string
231
+ env: Record<string, string>
232
+ fs: GokeFs // your filesystem adapter
233
+ }
116
234
 
117
- const transport = new StdioServerTransport()
118
- await mcp.connect(transport)
235
+ const transports = new Map<string, WebStandardStreamableHTTPServerTransport>()
236
+
237
+ export async function handleMcpRequest(request: Request): Promise<Response> {
238
+ // Pre-parse the body so we can use it for both routing decisions
239
+ // (is this an `initialize` request?) and as the pre-parsed body
240
+ // forwarded to the transport via `HandleRequestOptions.parsedBody`.
241
+ let parsedBody: unknown
242
+ if (request.method === "POST") {
243
+ parsedBody = await request.clone().json().catch(() => undefined)
244
+ }
245
+
246
+ const sessionId = request.headers.get("mcp-session-id")
247
+
248
+ // Existing session — route to its transport.
249
+ if (sessionId && transports.has(sessionId)) {
250
+ return transports.get(sessionId)!.handleRequest(request, { parsedBody })
251
+ }
252
+
253
+ // No session yet — must be an `initialize` request.
254
+ if (!isInitializeRequest(parsedBody)) {
255
+ return Response.json(
256
+ {
257
+ jsonrpc: "2.0",
258
+ error: { code: -32000, message: "Bad Request: No valid session ID provided" },
259
+ id: null,
260
+ },
261
+ { status: 400 },
262
+ )
263
+ }
264
+
265
+ // Derive the tenant from whatever header/cookie/JWT you use.
266
+ const tenantId = request.headers.get("x-tenant-id")
267
+ if (!tenantId) return new Response("missing x-tenant-id", { status: 401 })
268
+ const tenant = resolveTenant(tenantId)
269
+
270
+ // Clone the base cli with tenant-specific cwd/env/fs. Every tool
271
+ // call on this session now sees the tenant's state via `ctx`.
272
+ const tenantCli = baseCli.clone({
273
+ cwd: tenant.cwd,
274
+ env: { ...tenant.env, TENANT_ID: tenantId },
275
+ fs: tenant.fs,
276
+ })
277
+
278
+ const mcpServer = new McpServer(
279
+ { name: "notes-app-mcp", version: "1.0.0" },
280
+ { capabilities: {} },
281
+ )
282
+ addCliToolsToMcp({ cli: tenantCli, server: mcpServer })
283
+
284
+ const transport = new WebStandardStreamableHTTPServerTransport({
285
+ sessionIdGenerator: () => randomUUID(),
286
+ enableJsonResponse: true, // pure request/response; no SSE to manage
287
+ onsessioninitialized: (sid) => {
288
+ transports.set(sid, transport)
289
+ },
290
+ onsessionclosed: (sid) => {
291
+ transports.delete(sid)
292
+ },
293
+ })
294
+ transport.onclose = () => {
295
+ const sid = transport.sessionId
296
+ if (sid) transports.delete(sid)
297
+ }
298
+
299
+ await mcpServer.connect(transport)
300
+ return transport.handleRequest(request, { parsedBody })
301
+ }
302
+ ```
303
+
304
+ Now plug `handleMcpRequest` into whichever web runtime you use:
305
+
306
+ ```ts
307
+ // Spiceflow — runs on Node, Bun, and Cloudflare Workers with the same code
308
+ import { Spiceflow } from "spiceflow"
309
+
310
+ export const app = new Spiceflow()
311
+ .route({
312
+ method: "*",
313
+ path: "/mcp",
314
+ handler: ({ request }) => handleMcpRequest(request),
315
+ })
316
+
317
+ app.listen(3000)
318
+
319
+ // Cloudflare Workers / Deno / Bun
320
+ export default {
321
+ async fetch(request: Request): Promise<Response> {
322
+ const url = new URL(request.url)
323
+ if (url.pathname === "/mcp") return handleMcpRequest(request)
324
+ return new Response("not found", { status: 404 })
325
+ },
326
+ }
327
+
328
+ // Next.js app router
329
+ export async function POST(request: Request) {
330
+ return handleMcpRequest(request)
331
+ }
332
+ ```
333
+
334
+ **Key guarantees**
335
+
336
+ - Every tool call inside a session runs against `tenantCli`'s `cwd` / `env` / `fs` — not the base cli's and not another tenant's.
337
+ - `ctx.console.log` / `ctx.console.error` / `ctx.process.stdout.write` / `ctx.process.stderr.write` are captured into the `CallToolResult.content`. They never reach the host process stdio, so they can't corrupt the JSON-RPC channel or leak between users.
338
+ - `ctx.process.exit(code)` throws `GokeProcessExit` instead of killing the server. The tool call resolves as `{ isError: code !== 0, content: [captured output] }` and the next request keeps running.
339
+ - Actions that `throw` are caught and returned as `{ isError: true, content: [message, stderr] }`.
340
+
341
+ **Bypass hazards**
342
+
343
+ Only code that flows through `ctx` participates in the isolation. The following **bypass** it and will leak across tenants or kill the host process:
344
+
345
+ - `import fs from "node:fs"` inside a command action — reads/writes the real disk.
346
+ - `console.log(...)` / `console.error(...)` — writes to the real server stdio.
347
+ - `process.exit(1)` — terminates the entire host process.
348
+ - `process.cwd()` / `process.env.X` at module load time — snapshots the server's values, not the tenant's.
349
+
350
+ Port those to `ctx.fs`, `ctx.console`, `ctx.process.*` and you're multi-tenant-safe.
351
+
352
+ For a runnable end-to-end example (including two concurrent in-memory-fs tenants writing separate files), see [`src/__test__/http-multi-tenant.test.ts`](./src/__test__/http-multi-tenant.test.ts).
353
+
354
+ ### Lower-level primitive: `cli.createExecutionContext(override)`
355
+
356
+ If you're building a custom transport or a non-HTTP multi-tenant adapter, the underlying goke primitive is:
357
+
358
+ ```ts
359
+ import { GokeProcessExit, type GokeExecutionContextOverride } from "goke"
360
+
361
+ const override: GokeExecutionContextOverride = {
362
+ cwd: tenant.cwd,
363
+ env: tenant.env,
364
+ fs: tenant.fs,
365
+ stdin: tenant.stdin,
366
+ stdout: captureStdoutStream,
367
+ stderr: captureStderrStream,
368
+ exit: () => {}, // throw-only: the wrapper still throws GokeProcessExit
369
+ }
370
+
371
+ const ctx = cli.createExecutionContext(override)
372
+
373
+ try {
374
+ await action(...positionalArgs, options, ctx)
375
+ } catch (err) {
376
+ if (err instanceof GokeProcessExit) {
377
+ // handle the exit code — the host process is untouched
378
+ } else {
379
+ throw err
380
+ }
381
+ }
119
382
  ```
120
383
 
384
+ `addCliToolsToMcp` builds this context for you on every tool call. Call it yourself if you need finer control (e.g. per-request capture streams for custom routing, synthetic `argv`, or serving MCP from a non-Node runtime that implements `GokeFs` differently).
385
+
121
386
  ## Full example (with config persistence)
122
387
 
123
388
  This is the pattern used by [notion-mcp-cli](../notion-mcp-cli):
@@ -220,13 +485,14 @@ Registers MCP tool commands on a goke CLI instance.
220
485
  ### Exports
221
486
 
222
487
  ```ts
223
- // Main function
488
+ // MCP server → CLI (consume MCP tools as CLI commands)
224
489
  export { addMcpCommands } from '@goke/mcp'
225
-
226
- // Types
227
- export type { AddMcpCommandsOptions } from '@goke/mcp'
228
- export type { CachedMcpTools } from '@goke/mcp'
490
+ export type { AddMcpCommandsOptions, CachedMcpTools } from '@goke/mcp'
229
491
  export type { McpOAuthConfig, McpOAuthState } from '@goke/mcp'
492
+
493
+ // CLI → MCP server (expose CLI commands as MCP tools)
494
+ export { createMcpAction, addCliToolsToMcp } from '@goke/mcp'
495
+ export type { CreateMcpActionOptions, AddCliToolsToMcpOptions } from '@goke/mcp'
230
496
  ```
231
497
 
232
498
  ## OAuth flow
@@ -20,6 +20,8 @@ function createCli() {
20
20
  const message = `Hello ${options.name}!`;
21
21
  return options.caps ? message.toUpperCase() : message;
22
22
  });
23
+ // sum-values uses wrapJsonSchema whose output is `unknown`, so values are
24
+ // cast with Number() inside the action.
23
25
  cli
24
26
  .command("sum-values", "Add two numbers")
25
27
  .option("--left <left>", wrapJsonSchema({
@@ -31,7 +33,7 @@ function createCli() {
31
33
  description: "Right operand",
32
34
  }))
33
35
  .action((options) => ({
34
- sum: options.left + options.right,
36
+ sum: Number(options.left) + Number(options.right),
35
37
  }));
36
38
  cli
37
39
  .command("echo <message>", "Echo positional message")
@@ -41,7 +43,7 @@ function createCli() {
41
43
  description: "Repeat count",
42
44
  }))
43
45
  .action((message, options) => {
44
- return message.repeat(options.repeat);
46
+ return message.repeat(Number(options.repeat));
45
47
  });
46
48
  cli
47
49
  .command("string-options", "Infer option types from plain string descriptions")
@@ -397,3 +399,222 @@ describe("addCliToolsToMcp", () => {
397
399
  }
398
400
  });
399
401
  });
402
+ /**
403
+ * Spin up a live MCP client/server pair wired to a single cli.
404
+ *
405
+ * Used by the execution-context tests below to keep the boilerplate
406
+ * out of each test body.
407
+ */
408
+ async function withMcpClient(cli, fn) {
409
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
410
+ const server = new Server({ name: "test-server", version: "1.0.0" }, { capabilities: {} });
411
+ addCliToolsToMcp({ cli, server });
412
+ const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
413
+ try {
414
+ await server.connect(serverTransport);
415
+ await client.connect(clientTransport);
416
+ return await fn(client);
417
+ }
418
+ finally {
419
+ await client.close();
420
+ await server.close();
421
+ }
422
+ }
423
+ function textBlocks(result) {
424
+ const content = "content" in result ? result.content : [];
425
+ return content.filter((entry) => entry.type === "text").map((entry) => entry.text ?? "");
426
+ }
427
+ describe("addCliToolsToMcp execution context", () => {
428
+ it("passes an execution context as the third argument to the action", async () => {
429
+ const cli = goke("ctx-cli", {
430
+ cwd: "/workspace",
431
+ env: { TOKEN: "abc", USER: "tommy" },
432
+ stdin: "hello from stdin",
433
+ });
434
+ cli.command("inspect-ctx", "Return the injected execution context").action((_options, ctx) => {
435
+ return {
436
+ hasCtx: ctx != null,
437
+ hasConsole: typeof ctx?.console?.log === "function",
438
+ hasFs: typeof ctx?.fs?.readFile === "function",
439
+ cwd: ctx?.process?.cwd,
440
+ token: ctx?.process?.env?.TOKEN,
441
+ user: ctx?.process?.env?.USER,
442
+ stdin: ctx?.process?.stdin,
443
+ };
444
+ });
445
+ const result = await withMcpClient(cli, (client) => client.callTool({ name: "inspect-ctx", arguments: {} }));
446
+ expect(firstTextContent(result)).toMatchInlineSnapshot(`
447
+ "{
448
+ "hasCtx": true,
449
+ "hasConsole": true,
450
+ "hasFs": true,
451
+ "cwd": "/workspace",
452
+ "token": "abc",
453
+ "user": "tommy",
454
+ "stdin": "hello from stdin"
455
+ }"
456
+ `);
457
+ });
458
+ it("captures ctx.console.log output into the tool result content", async () => {
459
+ const cli = goke("logs-cli");
460
+ cli.command("noisy", "Write to ctx.console and return nothing").action((_options, ctx) => {
461
+ ctx.console.log("line one");
462
+ ctx.console.log("line", "two");
463
+ });
464
+ const result = await withMcpClient(cli, (client) => client.callTool({ name: "noisy", arguments: {} }));
465
+ expect(textBlocks(result)).toMatchInlineSnapshot(`
466
+ [
467
+ "line one
468
+ line two
469
+ ",
470
+ ]
471
+ `);
472
+ });
473
+ it("captures ctx.console.log output and still uses the action's return value", async () => {
474
+ const cli = goke("logs-plus-return-cli");
475
+ cli.command("both", "Log and return").action((_options, ctx) => {
476
+ ctx.console.log("before");
477
+ return "the-return-value";
478
+ });
479
+ const result = await withMcpClient(cli, (client) => client.callTool({ name: "both", arguments: {} }));
480
+ // Captured stdout first, then the stringified return value, as
481
+ // separate content blocks. Authors who want a single block can
482
+ // return a `{ content }` object to bypass this merging.
483
+ expect(textBlocks(result)).toMatchInlineSnapshot(`
484
+ [
485
+ "before
486
+ ",
487
+ "the-return-value",
488
+ ]
489
+ `);
490
+ });
491
+ it("treats ctx.process.exit(0) as a success result with captured content", async () => {
492
+ const cli = goke("exit-ok-cli");
493
+ cli.command("exit-ok", "Exit cleanly").action((_options, ctx) => {
494
+ ctx.console.log("all good");
495
+ ctx.process.exit(0);
496
+ });
497
+ const result = await withMcpClient(cli, (client) => client.callTool({ name: "exit-ok", arguments: {} }));
498
+ expect(result.isError).toBeFalsy();
499
+ expect(textBlocks(result)).toMatchInlineSnapshot(`
500
+ [
501
+ "all good
502
+ ",
503
+ ]
504
+ `);
505
+ });
506
+ it("treats ctx.process.exit(1) as an isError result with captured stderr", async () => {
507
+ const cli = goke("exit-fail-cli");
508
+ cli.command("exit-fail", "Exit with error").action((_options, ctx) => {
509
+ ctx.console.error("boom");
510
+ ctx.process.exit(1);
511
+ });
512
+ const result = await withMcpClient(cli, (client) => client.callTool({ name: "exit-fail", arguments: {} }));
513
+ expect(result.isError).toBe(true);
514
+ expect(textBlocks(result)).toMatchInlineSnapshot(`
515
+ [
516
+ "boom
517
+ ",
518
+ ]
519
+ `);
520
+ });
521
+ it("does not corrupt the MCP transport when the action writes to ctx.process.stdout directly", async () => {
522
+ const cli = goke("stdout-cli");
523
+ cli.command("write-stdout", "Write through ctx.process.stdout").action((_options, ctx) => {
524
+ ctx.process.stdout.write("from-process-stdout\n");
525
+ });
526
+ const result = await withMcpClient(cli, (client) => client.callTool({ name: "write-stdout", arguments: {} }));
527
+ expect(firstTextContent(result)).toBe("from-process-stdout\n");
528
+ });
529
+ it("keeps the server alive after a tool action calls ctx.process.exit", async () => {
530
+ const cli = goke("survive-cli");
531
+ cli.command("boom", "Exit with non-zero code").action((_options, ctx) => {
532
+ ctx.process.exit(2);
533
+ });
534
+ cli.command("ping", "Return a value").action(() => "pong");
535
+ await withMcpClient(cli, async (client) => {
536
+ const boomResult = await client.callTool({ name: "boom", arguments: {} });
537
+ expect(boomResult.isError).toBe(true);
538
+ // Server must still be able to serve subsequent tool calls.
539
+ const pingResult = await client.callTool({ name: "ping", arguments: {} });
540
+ expect(firstTextContent(pingResult)).toBe("pong");
541
+ });
542
+ });
543
+ it("does not include captured content when the action returns a ready-made CallToolResult", async () => {
544
+ const cli = goke("raw-cli");
545
+ cli.command("raw", "Return a raw CallToolResult").action((_options, ctx) => {
546
+ // This write should be ignored — returning a {content} object is
547
+ // the explicit escape hatch for authors who want full control.
548
+ ctx.console.log("ignored-capture");
549
+ return {
550
+ content: [
551
+ { type: "text", text: "authoritative" },
552
+ ],
553
+ };
554
+ });
555
+ const result = await withMcpClient(cli, (client) => client.callTool({ name: "raw", arguments: {} }));
556
+ expect(textBlocks(result)).toEqual(["authoritative"]);
557
+ });
558
+ it("captures ctx.console.error output on the success path", async () => {
559
+ const cli = goke("success-stderr-cli");
560
+ cli.command("warn-and-return", "Emit a warning and return a value").action((_options, ctx) => {
561
+ ctx.console.error("something suspicious");
562
+ return { ok: true };
563
+ });
564
+ const result = await withMcpClient(cli, (client) => client.callTool({ name: "warn-and-return", arguments: {} }));
565
+ // Captured stderr lands in its own text block so authors can spot
566
+ // the warning even though the action returned successfully. The
567
+ // stringified return value is appended after it.
568
+ expect(result.isError).toBeFalsy();
569
+ expect(textBlocks(result)).toMatchInlineSnapshot(`
570
+ [
571
+ "something suspicious
572
+ ",
573
+ "{
574
+ "ok": true
575
+ }",
576
+ ]
577
+ `);
578
+ });
579
+ it("captures ctx.process.stderr.write output on the success path", async () => {
580
+ const cli = goke("success-stderr-write-cli");
581
+ cli.command("warn-only", "Write to stderr and return undefined").action((_options, ctx) => {
582
+ ctx.process.stderr.write("low-level-warning\n");
583
+ });
584
+ const result = await withMcpClient(cli, (client) => client.callTool({ name: "warn-only", arguments: {} }));
585
+ expect(result.isError).toBeFalsy();
586
+ expect(textBlocks(result)).toEqual(["low-level-warning\n"]);
587
+ });
588
+ it("does not leak tool output into the cli's configured stdout/stderr", async () => {
589
+ const sentinelStdout = [];
590
+ const sentinelStderr = [];
591
+ const cli = goke("sentinel-cli", {
592
+ stdout: { write: (data) => { sentinelStdout.push(data); } },
593
+ stderr: { write: (data) => { sentinelStderr.push(data); } },
594
+ });
595
+ cli.command("noisy", "Write to both streams").action((_options, ctx) => {
596
+ ctx.console.log("stdout-chatter");
597
+ ctx.console.error("stderr-chatter");
598
+ ctx.process.stdout.write("direct-stdout\n");
599
+ ctx.process.stderr.write("direct-stderr\n");
600
+ return "value";
601
+ });
602
+ const result = await withMcpClient(cli, (client) => client.callTool({ name: "noisy", arguments: {} }));
603
+ // Everything lands in the CallToolResult — the cli's configured
604
+ // host streams must not receive a single byte during a tool call.
605
+ expect(sentinelStdout.join("")).toBe("");
606
+ expect(sentinelStderr.join("")).toBe("");
607
+ expect(textBlocks(result).join("|")).toBe("stdout-chatter\ndirect-stdout\n|stderr-chatter\ndirect-stderr\n|value");
608
+ });
609
+ it("invokes command actions with the owning cli as `this`", async () => {
610
+ const cli = goke("this-binding-cli");
611
+ let seenThis;
612
+ cli.command("whoami", "Report this-binding").action(function (_options, _ctx) {
613
+ seenThis = this;
614
+ return "ok";
615
+ });
616
+ await withMcpClient(cli, (client) => client.callTool({ name: "whoami", arguments: {} }));
617
+ // Same binding Goke#runMatchedCommand uses for parse-path actions.
618
+ expect(seenThis).toBe(cli);
619
+ });
620
+ });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Tests for createMcpAction — turning a CLI into a stdio MCP server.
3
+ *
4
+ * Uses InMemoryTransport (via createTransport option) to avoid actual stdio.
5
+ * Simulates the goke runtime by setting matchedCommandName before calling the action.
6
+ */
7
+ export {};
8
+ //# sourceMappingURL=create-mcp-action.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create-mcp-action.test.d.ts","sourceRoot":"","sources":["../../src/__test__/create-mcp-action.test.ts"],"names":[],"mappings":"AAAA;;;;;GAKG"}