@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 +295 -29
- package/dist/__test__/add-cli-tools-to-mcp.test.js +223 -2
- package/dist/__test__/create-mcp-action.test.d.ts +8 -0
- package/dist/__test__/create-mcp-action.test.d.ts.map +1 -0
- package/dist/__test__/create-mcp-action.test.js +276 -0
- 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/cli-to-mcp.d.ts +28 -0
- package/dist/cli-to-mcp.d.ts.map +1 -1
- package/dist/cli-to-mcp.js +162 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- 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 +355 -0
- package/src/__test__/http-multi-tenant.test.ts +383 -0
- package/src/cli-to-mcp.ts +236 -6
- package/src/index.ts +2 -2
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
|
-
##
|
|
65
|
+
## Expose a CLI as an MCP server
|
|
66
66
|
|
|
67
|
-
`
|
|
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 {
|
|
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("
|
|
77
|
+
.command("search", "Search pages")
|
|
87
78
|
.option("--query <query>", z.string().describe("Search query"))
|
|
88
|
-
.
|
|
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
|
-
|
|
165
|
+
Also works with the high-level `McpServer`:
|
|
102
166
|
|
|
103
|
-
```
|
|
104
|
-
|
|
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
|
-
|
|
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 {
|
|
111
|
-
import {
|
|
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
|
-
|
|
115
|
-
|
|
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
|
|
118
|
-
|
|
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
|
-
//
|
|
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"}
|