@goke/mcp 0.1.0 → 0.1.2
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 +46 -10
- package/dist/__test__/add-cli-tools-to-mcp.test.js +5 -18
- package/dist/__test__/create-mcp-action.test.js +119 -1
- package/dist/__test__/http-multi-tenant.test.d.ts +14 -10
- package/dist/__test__/http-multi-tenant.test.d.ts.map +1 -1
- package/dist/__test__/http-multi-tenant.test.js +51 -36
- package/dist/cli-to-mcp.d.ts.map +1 -1
- package/dist/cli-to-mcp.js +3 -1
- package/package.json +2 -2
- package/src/__test__/add-cli-tools-to-mcp.test.ts +5 -18
- package/src/__test__/create-mcp-action.test.ts +152 -1
- package/src/__test__/http-multi-tenant.test.ts +56 -38
- package/src/cli-to-mcp.ts +4 -1
package/README.md
CHANGED
|
@@ -95,6 +95,7 @@ const cli = goke("my-cli")
|
|
|
95
95
|
cli
|
|
96
96
|
.command("search", "Search pages")
|
|
97
97
|
.option("--query <query>", z.string().describe("Search query"))
|
|
98
|
+
.required()
|
|
98
99
|
.option("--limit [limit]", z.number().default(10).describe("Max results"))
|
|
99
100
|
.action((options) => {
|
|
100
101
|
return { results: findPages(options.query, options.limit) }
|
|
@@ -129,6 +130,8 @@ my-cli mcp
|
|
|
129
130
|
|
|
130
131
|
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.
|
|
131
132
|
|
|
133
|
+
**`inputSchema.required` is not the CLI `<value>` syntax.** `--query <query>` means the flag needs a value if it is present. The flag itself is still optional unless you chain `.required()`. Required **positionals** like `<env>` always go in `required`. `.required()` flags go in `required` too.
|
|
134
|
+
|
|
132
135
|
### Installing the MCP server in clients
|
|
133
136
|
|
|
134
137
|
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:
|
|
@@ -194,13 +197,15 @@ addCliToolsToMcp({ cli, server: mcp })
|
|
|
194
197
|
|
|
195
198
|
## Multi-tenant remote MCP over HTTP
|
|
196
199
|
|
|
197
|
-
When you expose a cli as a **remote** MCP
|
|
200
|
+
When you expose a cli as a **remote** MCP over HTTP, one process handles many concurrent users. Each request 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.
|
|
201
|
+
|
|
202
|
+
Do **not** keep MCP session IDs. Streamable HTTP can run **stateless**: `sessionIdGenerator: undefined`. Each POST builds a fresh `Server` and transport, then closes them. That works on Cloudflare Workers and any other isolate that does not keep process memory.
|
|
198
203
|
|
|
199
204
|
The recipe is:
|
|
200
205
|
|
|
201
206
|
1. Define the cli **once**.
|
|
202
|
-
2. On every
|
|
203
|
-
3. Inside command actions, always use the **injected** `ctx`
|
|
207
|
+
2. On **every POST**, resolve the tenant from the request (JWT, cookie, header), **clone** the cli with `{ cwd, env, fs }`, and mount it on a fresh `Server` via `addCliToolsToMcp({ cli: tenantClone, server })`.
|
|
208
|
+
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.
|
|
204
209
|
|
|
205
210
|
### Write commands against `ctx`
|
|
206
211
|
|
|
@@ -235,7 +240,31 @@ cli
|
|
|
235
240
|
|
|
236
241
|
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.
|
|
237
242
|
|
|
238
|
-
|
|
243
|
+
Pass `sessionIdGenerator: undefined` so the transport does not emit or expect `mcp-session-id`. Do not store transports in a `Map`. JSON-RPC `initialize`, `tools/list`, and `tools/call` are separate POSTs. Each one clones the cli from the request's tenant identity.
|
|
244
|
+
|
|
245
|
+
This is MCP SDK v1 Streamable HTTP **without optional transport sessions** (protocol revisions through `2025-11-25`). It is not the later `2026-07-28` protocol, which drops `initialize`. Session IDs are optional in `2025-11-25`. Stateless servers omit them.
|
|
246
|
+
|
|
247
|
+
The Streamable HTTP spec requires **Origin** checks on every request (DNS rebinding). Reject a present, disallowed `Origin` with **403**. Missing `Origin` is normal for non-browser MCP clients.
|
|
248
|
+
|
|
249
|
+
```
|
|
250
|
+
POST /mcp (JWT / x-tenant-id)
|
|
251
|
+
│
|
|
252
|
+
v
|
|
253
|
+
resolveTenant(id) ──> baseCli.clone({ cwd, env, fs })
|
|
254
|
+
│
|
|
255
|
+
v
|
|
256
|
+
fresh Server + transport
|
|
257
|
+
sessionIdGenerator: undefined
|
|
258
|
+
│
|
|
259
|
+
v
|
|
260
|
+
addCliToolsToMcp ──> handleRequest
|
|
261
|
+
│
|
|
262
|
+
v
|
|
263
|
+
Response
|
|
264
|
+
│
|
|
265
|
+
v
|
|
266
|
+
close transport + server ──> nothing stored
|
|
267
|
+
```
|
|
239
268
|
|
|
240
269
|
You build one `handleMcpRequest(request: Request): Promise<Response>` function and mount it wherever you route HTTP:
|
|
241
270
|
|
|
@@ -251,12 +280,20 @@ declare function resolveTenant(tenantId: string): {
|
|
|
251
280
|
fs: GokeFs
|
|
252
281
|
}
|
|
253
282
|
|
|
283
|
+
declare function isAllowedOrigin(origin: string): boolean
|
|
284
|
+
|
|
254
285
|
export async function handleMcpRequest(request: Request): Promise<Response> {
|
|
255
|
-
|
|
256
|
-
if (
|
|
257
|
-
|
|
286
|
+
const origin = request.headers.get("origin")
|
|
287
|
+
if (origin && !isAllowedOrigin(origin)) {
|
|
288
|
+
return new Response(null, { status: 403 })
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (request.method !== "POST") {
|
|
292
|
+
return new Response(null, { status: 405, headers: { Allow: "POST" } })
|
|
258
293
|
}
|
|
259
294
|
|
|
295
|
+
const parsedBody = await request.clone().json().catch(() => undefined)
|
|
296
|
+
|
|
260
297
|
const tenantId = request.headers.get("x-tenant-id")
|
|
261
298
|
if (!tenantId) return new Response("missing x-tenant-id", { status: 401 })
|
|
262
299
|
const tenant = resolveTenant(tenantId)
|
|
@@ -277,7 +314,6 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
|
|
277
314
|
sessionIdGenerator: undefined,
|
|
278
315
|
enableJsonResponse: true,
|
|
279
316
|
})
|
|
280
|
-
|
|
281
317
|
await mcpServer.connect(transport)
|
|
282
318
|
try {
|
|
283
319
|
return await transport.handleRequest(request, { parsedBody })
|
|
@@ -320,7 +356,7 @@ export async function POST(request: Request) {
|
|
|
320
356
|
|
|
321
357
|
**Key guarantees**
|
|
322
358
|
|
|
323
|
-
- Every
|
|
359
|
+
- Every request runs against `tenantCli`'s `cwd` / `env` / `fs` — not the base cli's and not another tenant's.
|
|
324
360
|
- `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.
|
|
325
361
|
- `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.
|
|
326
362
|
- Actions that `throw` are caught and returned as `{ isError: true, content: [message, stderr] }`.
|
|
@@ -336,7 +372,7 @@ Only code that flows through `ctx` participates in the isolation. The following
|
|
|
336
372
|
|
|
337
373
|
Port those to `ctx.fs`, `ctx.console`, `ctx.process.*` and you're multi-tenant-safe.
|
|
338
374
|
|
|
339
|
-
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).
|
|
375
|
+
For a runnable end-to-end example (including two concurrent in-memory-fs tenants writing separate files, with a new server per POST), see [`src/__test__/http-multi-tenant.test.ts`](./src/__test__/http-multi-tenant.test.ts).
|
|
340
376
|
|
|
341
377
|
### Lower-level primitive: `cli.createExecutionContext(override)`
|
|
342
378
|
|
|
@@ -15,6 +15,7 @@ function createCli() {
|
|
|
15
15
|
cli
|
|
16
16
|
.command("say hi", "Say hello")
|
|
17
17
|
.option("--name <name>", z.string().describe("Person to greet"))
|
|
18
|
+
.required()
|
|
18
19
|
.option("--caps", z.boolean().default(false).describe("Uppercase output"))
|
|
19
20
|
.action((options) => {
|
|
20
21
|
const message = `Hello ${options.name}!`;
|
|
@@ -178,11 +179,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
178
179
|
"type": "number",
|
|
179
180
|
"description": "Right operand"
|
|
180
181
|
}
|
|
181
|
-
}
|
|
182
|
-
"required": [
|
|
183
|
-
"left",
|
|
184
|
-
"right"
|
|
185
|
-
]
|
|
182
|
+
}
|
|
186
183
|
}
|
|
187
184
|
},
|
|
188
185
|
{
|
|
@@ -224,10 +221,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
224
221
|
"type": "boolean",
|
|
225
222
|
"description": "Dry run flag"
|
|
226
223
|
}
|
|
227
|
-
}
|
|
228
|
-
"required": [
|
|
229
|
-
"title"
|
|
230
|
-
]
|
|
224
|
+
}
|
|
231
225
|
}
|
|
232
226
|
}
|
|
233
227
|
]"
|
|
@@ -280,11 +274,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
280
274
|
"type": "number",
|
|
281
275
|
"description": "Right operand"
|
|
282
276
|
}
|
|
283
|
-
}
|
|
284
|
-
"required": [
|
|
285
|
-
"left",
|
|
286
|
-
"right"
|
|
287
|
-
]
|
|
277
|
+
}
|
|
288
278
|
}
|
|
289
279
|
},
|
|
290
280
|
{
|
|
@@ -326,10 +316,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
326
316
|
"type": "boolean",
|
|
327
317
|
"description": "Dry run flag"
|
|
328
318
|
}
|
|
329
|
-
}
|
|
330
|
-
"required": [
|
|
331
|
-
"title"
|
|
332
|
-
]
|
|
319
|
+
}
|
|
333
320
|
}
|
|
334
321
|
}
|
|
335
322
|
]"
|
|
@@ -29,7 +29,9 @@ describe("createMcpAction", () => {
|
|
|
29
29
|
cli
|
|
30
30
|
.command("add", "Add numbers")
|
|
31
31
|
.option("--a <a>", z.number().describe("First"))
|
|
32
|
+
.required()
|
|
32
33
|
.option("--b <b>", z.number().describe("Second"))
|
|
34
|
+
.required()
|
|
33
35
|
.action((options) => ({ sum: options.a + options.b }));
|
|
34
36
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
35
37
|
cli.command("mcp", "Start MCP server").action(createMcpAction({
|
|
@@ -186,7 +188,7 @@ describe("createMcpAction", () => {
|
|
|
186
188
|
expect(searchTool.description).toBe("Search for items");
|
|
187
189
|
expect(searchTool.inputSchema.properties).toHaveProperty("query");
|
|
188
190
|
expect(searchTool.inputSchema.properties).toHaveProperty("limit");
|
|
189
|
-
expect(searchTool.inputSchema.required).
|
|
191
|
+
expect(searchTool.inputSchema.required).toBeUndefined();
|
|
190
192
|
const deployTool = tools.tools.find((t) => t.name === "deploy");
|
|
191
193
|
expect(deployTool.inputSchema.properties).toHaveProperty("env");
|
|
192
194
|
expect(deployTool.inputSchema.properties).toHaveProperty("dryRun");
|
|
@@ -229,6 +231,122 @@ describe("createMcpAction", () => {
|
|
|
229
231
|
await client.close();
|
|
230
232
|
}
|
|
231
233
|
});
|
|
234
|
+
it("keeps --flag <value> in properties but not in required", async () => {
|
|
235
|
+
const cli = goke("strada");
|
|
236
|
+
cli
|
|
237
|
+
.command("projects create <slug>", "Create a project")
|
|
238
|
+
.option("--traces-days <days>", z.string().optional().describe("Traces retention days"))
|
|
239
|
+
.option("--logs-days <days>", z.string().optional().describe("Logs retention days"))
|
|
240
|
+
.option("--errors-days <days>", z.string().optional().describe("Errors retention days"))
|
|
241
|
+
.option("--metrics-days <days>", z.string().optional().describe("Metrics retention days"))
|
|
242
|
+
.option("--all-days <days>", z.string().optional().describe("Retention for all signals"))
|
|
243
|
+
.action((slug, options) => ({ slug, ...options }));
|
|
244
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
245
|
+
cli.command("mcp", "Start MCP server").action(createMcpAction({
|
|
246
|
+
cli,
|
|
247
|
+
createTransport: () => serverTransport,
|
|
248
|
+
}));
|
|
249
|
+
cli.matchedCommandName = "mcp";
|
|
250
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp");
|
|
251
|
+
await mcpCommand.commandAction({});
|
|
252
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
253
|
+
await client.connect(clientTransport);
|
|
254
|
+
try {
|
|
255
|
+
const tools = await client.listTools();
|
|
256
|
+
const createTool = tools.tools.find((t) => t.name === "projects_create");
|
|
257
|
+
expect(createTool.inputSchema.properties).toHaveProperty("slug");
|
|
258
|
+
expect(createTool.inputSchema.properties).toHaveProperty("tracesDays");
|
|
259
|
+
expect(createTool.inputSchema.properties).toHaveProperty("logsDays");
|
|
260
|
+
expect(createTool.inputSchema.properties).toHaveProperty("allDays");
|
|
261
|
+
expect(createTool.inputSchema.required).toEqual(["slug"]);
|
|
262
|
+
const result = await client.callTool({
|
|
263
|
+
name: "projects_create",
|
|
264
|
+
arguments: { slug: "my-app" },
|
|
265
|
+
});
|
|
266
|
+
expect(firstTextContent(result)).toBe('{\n "slug": "my-app"\n}');
|
|
267
|
+
await expect(client.callTool({
|
|
268
|
+
name: "projects_create",
|
|
269
|
+
arguments: {},
|
|
270
|
+
})).rejects.toThrow("Missing required argument: slug");
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
await client.close();
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
it("puts .required() --flag <value> in inputSchema.required", async () => {
|
|
277
|
+
const cli = goke("checks");
|
|
278
|
+
cli
|
|
279
|
+
.command("checks create", "Create a check")
|
|
280
|
+
.option("--url <url>", z.string().describe("URL to check"))
|
|
281
|
+
.required()
|
|
282
|
+
.option("--name <name>", z.string().describe("Check name"))
|
|
283
|
+
.required()
|
|
284
|
+
.option("--timeout [ms]", z.number().optional().describe("Timeout"))
|
|
285
|
+
.action((options) => options);
|
|
286
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
287
|
+
cli.command("mcp", "Start MCP server").action(createMcpAction({
|
|
288
|
+
cli,
|
|
289
|
+
createTransport: () => serverTransport,
|
|
290
|
+
}));
|
|
291
|
+
cli.matchedCommandName = "mcp";
|
|
292
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp");
|
|
293
|
+
await mcpCommand.commandAction({});
|
|
294
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
295
|
+
await client.connect(clientTransport);
|
|
296
|
+
try {
|
|
297
|
+
const tools = await client.listTools();
|
|
298
|
+
const createTool = tools.tools.find((t) => t.name === "checks_create");
|
|
299
|
+
expect(createTool.inputSchema.properties).toHaveProperty("url");
|
|
300
|
+
expect(createTool.inputSchema.properties).toHaveProperty("name");
|
|
301
|
+
expect(createTool.inputSchema.properties).toHaveProperty("timeout");
|
|
302
|
+
expect(createTool.inputSchema.required).toEqual(["url", "name"]);
|
|
303
|
+
const result = await client.callTool({
|
|
304
|
+
name: "checks_create",
|
|
305
|
+
arguments: { url: "https://example.com", name: "home" },
|
|
306
|
+
});
|
|
307
|
+
expect(firstTextContent(result)).toContain("https://example.com");
|
|
308
|
+
await expect(client.callTool({
|
|
309
|
+
name: "checks_create",
|
|
310
|
+
arguments: { name: "home" },
|
|
311
|
+
})).rejects.toThrow("Missing required argument: url");
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
await client.close();
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
it("does not put wrapJsonSchema flags in required", async () => {
|
|
318
|
+
const cli = goke("wrapped");
|
|
319
|
+
cli
|
|
320
|
+
.command("config set", "Set a config value")
|
|
321
|
+
.option("--key <key>", wrapJsonSchema({ type: "string", description: "Config key" }))
|
|
322
|
+
.option("--value <value>", wrapJsonSchema({ type: "string", description: "Config value" }))
|
|
323
|
+
.action((options) => options);
|
|
324
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
325
|
+
cli.command("mcp", "Start MCP server").action(createMcpAction({
|
|
326
|
+
cli,
|
|
327
|
+
createTransport: () => serverTransport,
|
|
328
|
+
}));
|
|
329
|
+
cli.matchedCommandName = "mcp";
|
|
330
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp");
|
|
331
|
+
await mcpCommand.commandAction({});
|
|
332
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
333
|
+
await client.connect(clientTransport);
|
|
334
|
+
try {
|
|
335
|
+
const tools = await client.listTools();
|
|
336
|
+
const setTool = tools.tools.find((t) => t.name === "config_set");
|
|
337
|
+
expect(setTool.inputSchema.properties).toHaveProperty("key");
|
|
338
|
+
expect(setTool.inputSchema.properties).toHaveProperty("value");
|
|
339
|
+
expect(setTool.inputSchema.required).toBeUndefined();
|
|
340
|
+
const result = await client.callTool({
|
|
341
|
+
name: "config_set",
|
|
342
|
+
arguments: {},
|
|
343
|
+
});
|
|
344
|
+
expect(firstTextContent(result)).toBe("{}");
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
await client.close();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
232
350
|
it("returns empty tool list when only the mcp command exists", async () => {
|
|
233
351
|
const cli = goke("empty-app");
|
|
234
352
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Multi-tenant remote-MCP test.
|
|
3
3
|
*
|
|
4
|
-
* Proves that one goke cli exposed over
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Proves that one goke cli exposed over stateless MCP streamable HTTP
|
|
5
|
+
* can serve multiple concurrent users with fully isolated state
|
|
6
|
+
* (in-memory fs + cwd + env) — no shared host process stdio, no
|
|
7
|
+
* cross-tenant leaks, no mcp-session-id map. Isolation comes from
|
|
8
|
+
* the `x-tenant-id` header on each POST, not from `Mcp-Session-Id`.
|
|
8
9
|
*
|
|
9
10
|
* Wiring choices worth calling out:
|
|
10
11
|
*
|
|
@@ -14,12 +15,15 @@
|
|
|
14
15
|
* transport's `fetch` hook without ever binding a TCP socket
|
|
15
16
|
* or spinning up `node:http` / Express. Same wire protocol,
|
|
16
17
|
* zero sockets.
|
|
17
|
-
* - `
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* - `sessionIdGenerator: undefined` is stateless mode. The
|
|
19
|
+
* transport does not emit or expect `mcp-session-id`. Each
|
|
20
|
+
* POST builds a fresh Server + transport, then closes them.
|
|
21
|
+
* - `enableJsonResponse: true` switches the transport off SSE
|
|
22
|
+
* and into pure request/response JSON. GET SSE opens are
|
|
23
|
+
* answered with `405`, which the client treats as "server
|
|
24
|
+
* does not offer SSE" and moves on (see `_startOrAuthSse`
|
|
25
|
+
* in the SDK client).
|
|
26
|
+
* - Each request gets its own cli **clone** via
|
|
23
27
|
* `baseCli.clone({ cwd, env, fs })`. The clone inherits the
|
|
24
28
|
* command tree but owns its own `{ cwd, env, fs }`, which is
|
|
25
29
|
* what `runCliTool` forwards into every action through
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-multi-tenant.test.d.ts","sourceRoot":"","sources":["../../src/__test__/http-multi-tenant.test.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"http-multi-tenant.test.d.ts","sourceRoot":"","sources":["../../src/__test__/http-multi-tenant.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG"}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Multi-tenant remote-MCP test.
|
|
3
3
|
*
|
|
4
|
-
* Proves that one goke cli exposed over
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Proves that one goke cli exposed over stateless MCP streamable HTTP
|
|
5
|
+
* can serve multiple concurrent users with fully isolated state
|
|
6
|
+
* (in-memory fs + cwd + env) — no shared host process stdio, no
|
|
7
|
+
* cross-tenant leaks, no mcp-session-id map. Isolation comes from
|
|
8
|
+
* the `x-tenant-id` header on each POST, not from `Mcp-Session-Id`.
|
|
8
9
|
*
|
|
9
10
|
* Wiring choices worth calling out:
|
|
10
11
|
*
|
|
@@ -14,12 +15,15 @@
|
|
|
14
15
|
* transport's `fetch` hook without ever binding a TCP socket
|
|
15
16
|
* or spinning up `node:http` / Express. Same wire protocol,
|
|
16
17
|
* zero sockets.
|
|
17
|
-
* - `
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* - `sessionIdGenerator: undefined` is stateless mode. The
|
|
19
|
+
* transport does not emit or expect `mcp-session-id`. Each
|
|
20
|
+
* POST builds a fresh Server + transport, then closes them.
|
|
21
|
+
* - `enableJsonResponse: true` switches the transport off SSE
|
|
22
|
+
* and into pure request/response JSON. GET SSE opens are
|
|
23
|
+
* answered with `405`, which the client treats as "server
|
|
24
|
+
* does not offer SSE" and moves on (see `_startOrAuthSse`
|
|
25
|
+
* in the SDK client).
|
|
26
|
+
* - Each request gets its own cli **clone** via
|
|
23
27
|
* `baseCli.clone({ cwd, env, fs })`. The clone inherits the
|
|
24
28
|
* command tree but owns its own `{ cwd, env, fs }`, which is
|
|
25
29
|
* what `runCliTool` forwards into every action through
|
|
@@ -86,6 +90,7 @@ function buildBaseCli() {
|
|
|
86
90
|
cli
|
|
87
91
|
.command("save <filename>", "Save content to a file in the tenant workspace")
|
|
88
92
|
.option("--content <content>", z.string().describe("File content"))
|
|
93
|
+
.required()
|
|
89
94
|
.action(async (filename, options, ctx) => {
|
|
90
95
|
const full = path.posix.join(ctx.process.cwd, filename);
|
|
91
96
|
await ctx.fs.writeFile(full, options.content);
|
|
@@ -101,9 +106,10 @@ function buildBaseCli() {
|
|
|
101
106
|
return cli;
|
|
102
107
|
}
|
|
103
108
|
/**
|
|
104
|
-
* Build a `FetchLike` that
|
|
105
|
-
*
|
|
106
|
-
* from `x-tenant-id
|
|
109
|
+
* Build a `FetchLike` that handles each MCP POST with a fresh
|
|
110
|
+
* `WebStandardStreamableHTTPServerTransport` and cli clone.
|
|
111
|
+
* Tenant identity comes from `x-tenant-id` on every request.
|
|
112
|
+
* Nothing is keyed by `mcp-session-id`.
|
|
107
113
|
*/
|
|
108
114
|
function createMultiTenantFetch(options) {
|
|
109
115
|
const { baseCli, resolveTenant } = options;
|
|
@@ -115,17 +121,15 @@ function createMultiTenantFetch(options) {
|
|
|
115
121
|
if (sessionHeader) {
|
|
116
122
|
sessionHeaders.push(sessionHeader);
|
|
117
123
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
const origin = headers.get("origin");
|
|
125
|
+
if (origin && origin !== "http://in-memory-mcp.test") {
|
|
126
|
+
return new Response(null, { status: 403 });
|
|
127
|
+
}
|
|
128
|
+
if (method !== "POST") {
|
|
129
|
+
return new Response(null, { status: 405, headers: { Allow: "POST" } });
|
|
123
130
|
}
|
|
124
|
-
// Parse POST body once and hand it to the transport via
|
|
125
|
-
// `parsedBody` in `HandleRequestOptions` so we don't have to
|
|
126
|
-
// worry about Request body streams being single-use.
|
|
127
131
|
let parsedBody = undefined;
|
|
128
|
-
if (
|
|
132
|
+
if (init?.body != null) {
|
|
129
133
|
const rawBody = init.body;
|
|
130
134
|
const bodyText = typeof rawBody === "string"
|
|
131
135
|
? rawBody
|
|
@@ -155,10 +159,13 @@ function createMultiTenantFetch(options) {
|
|
|
155
159
|
enableJsonResponse: true,
|
|
156
160
|
});
|
|
157
161
|
await mcpServer.connect(transport);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
+
try {
|
|
163
|
+
return await transport.handleRequest(request, { parsedBody });
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
await transport.close();
|
|
167
|
+
await mcpServer.close();
|
|
168
|
+
}
|
|
162
169
|
};
|
|
163
170
|
return { fetch: customFetch, sessionHeaders };
|
|
164
171
|
}
|
|
@@ -186,8 +193,6 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
186
193
|
return tenant;
|
|
187
194
|
},
|
|
188
195
|
});
|
|
189
|
-
// The URL is a placeholder — the in-process fetch never looks
|
|
190
|
-
// at the host, just the method/headers/body.
|
|
191
196
|
const endpoint = new URL("http://in-memory-mcp.test/mcp");
|
|
192
197
|
async function connectTenant(tenantId) {
|
|
193
198
|
const transport = new StreamableHTTPClientTransport(endpoint, {
|
|
@@ -213,15 +218,10 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
213
218
|
const aliceClient = await connectTenant("tenant-a");
|
|
214
219
|
const bobClient = await connectTenant("tenant-b");
|
|
215
220
|
try {
|
|
216
|
-
// Each client sees the same tool catalog — it comes from the
|
|
217
|
-
// shared cli definition.
|
|
218
221
|
const aliceTools = (await aliceClient.listTools()).tools.map((t) => t.name).sort();
|
|
219
222
|
const bobTools = (await bobClient.listTools()).tools.map((t) => t.name).sort();
|
|
220
223
|
expect(aliceTools).toEqual(["load", "save"]);
|
|
221
224
|
expect(bobTools).toEqual(["load", "save"]);
|
|
222
|
-
// Both tenants write a file called `notes.txt` with different
|
|
223
|
-
// content. Since each request clones the cli with that tenant's
|
|
224
|
-
// cwd + fs, the writes land in separate Maps.
|
|
225
225
|
const aliceSave = await aliceClient.callTool({
|
|
226
226
|
name: "save",
|
|
227
227
|
arguments: { filename: "notes.txt", content: "alice-secret" },
|
|
@@ -234,7 +234,6 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
234
234
|
expect(firstTextBlock(aliceSave)).toContain("tenant-a");
|
|
235
235
|
expect(firstTextBlock(bobSave)).toContain("/workspace-b/notes.txt");
|
|
236
236
|
expect(firstTextBlock(bobSave)).toContain("tenant-b");
|
|
237
|
-
// Each tenant reads back what it wrote.
|
|
238
237
|
const aliceLoad = await aliceClient.callTool({
|
|
239
238
|
name: "load",
|
|
240
239
|
arguments: { filename: "notes.txt" },
|
|
@@ -247,8 +246,6 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
247
246
|
expect(firstTextBlock(aliceLoad)).not.toContain("bob-secret");
|
|
248
247
|
expect(firstTextBlock(bobLoad)).toContain("bob-secret");
|
|
249
248
|
expect(firstTextBlock(bobLoad)).not.toContain("alice-secret");
|
|
250
|
-
// Sanity check: the underlying in-memory maps really are
|
|
251
|
-
// disjoint. Tenant A's fs only has tenant A's file.
|
|
252
249
|
const tenantAFs = tenants.get("tenant-a").fs;
|
|
253
250
|
const tenantBFs = tenants.get("tenant-b").fs;
|
|
254
251
|
expect([...tenantAFs.files.keys()]).toEqual(["/workspace-a/notes.txt"]);
|
|
@@ -277,4 +274,22 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
277
274
|
await bobClient.close();
|
|
278
275
|
}
|
|
279
276
|
});
|
|
277
|
+
it("rejects a present Origin that is not allowed", async () => {
|
|
278
|
+
const { fetch } = createMultiTenantFetch({
|
|
279
|
+
baseCli: buildBaseCli(),
|
|
280
|
+
resolveTenant: () => {
|
|
281
|
+
throw new Error("tenant must not be resolved for a forbidden origin");
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
const response = await fetch("http://in-memory-mcp.test/mcp", {
|
|
285
|
+
method: "POST",
|
|
286
|
+
headers: {
|
|
287
|
+
origin: "https://evil.example",
|
|
288
|
+
"x-tenant-id": "tenant-a",
|
|
289
|
+
"content-type": "application/json",
|
|
290
|
+
},
|
|
291
|
+
body: "{}",
|
|
292
|
+
});
|
|
293
|
+
expect(response.status).toBe(403);
|
|
294
|
+
});
|
|
280
295
|
});
|
package/dist/cli-to-mcp.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli-to-mcp.d.ts","sourceRoot":"","sources":["../src/cli-to-mcp.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AACxE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+CAA+C,CAAC;AAS/E,OAAO,EAKL,KAAK,IAAI,EAIV,MAAM,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"cli-to-mcp.d.ts","sourceRoot":"","sources":["../src/cli-to-mcp.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AACxE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+CAA+C,CAAC;AAS/E,OAAO,EAKL,KAAK,IAAI,EAIV,MAAM,MAAM,CAAC;AAyFd,MAAM,WAAW,uBAAuB;IACtC,GAAG,EAAE,IAAI,CAAC;IACV,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,aAAa,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC;IACjD,gBAAgB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,MAAM,CAAC;CACpD;AAufD,MAAM,WAAW,sBAAsB;IACrC,mEAAmE;IACnE,GAAG,EAAE,IAAI,CAAC;IACV,iGAAiG;IACjG,aAAa,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC;IACjD,iCAAiC;IACjC,gBAAgB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,MAAM,CAAC;IACnD,oEAAoE;IACpE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8CAA8C;IAC9C,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,iFAAiF;IACjF,eAAe,CAAC,EAAE,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;CACxD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,sBAAsB,GAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAsClG;AAED,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB,GAAG,IAAI,CAyCvE"}
|
package/dist/cli-to-mcp.js
CHANGED
|
@@ -359,10 +359,12 @@ function createBinding(cli, command, toolName) {
|
|
|
359
359
|
requiredNames.push(arg.value);
|
|
360
360
|
}
|
|
361
361
|
}
|
|
362
|
+
// `--days <days>` means "value required if the flag is present". The flag
|
|
363
|
+
// itself is required only when `.required()` set `flagRequired`.
|
|
362
364
|
for (const option of options) {
|
|
363
365
|
const normalized = normalizeOptionSchema(option);
|
|
364
366
|
properties[option.name] = normalized.schema;
|
|
365
|
-
if (option.
|
|
367
|
+
if (option.flagRequired && option.default === undefined) {
|
|
366
368
|
requiredNames.push(option.name);
|
|
367
369
|
}
|
|
368
370
|
optionBindings.push({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goke/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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.
|
|
54
|
+
"goke": "^6.17.0"
|
|
55
55
|
},
|
|
56
56
|
"scripts": {
|
|
57
57
|
"clean": "rm -rf dist",
|
|
@@ -18,6 +18,7 @@ function createCli() {
|
|
|
18
18
|
cli
|
|
19
19
|
.command("say hi", "Say hello")
|
|
20
20
|
.option("--name <name>", z.string().describe("Person to greet"))
|
|
21
|
+
.required()
|
|
21
22
|
.option("--caps", z.boolean().default(false).describe("Uppercase output"))
|
|
22
23
|
.action((options) => {
|
|
23
24
|
const message = `Hello ${options.name}!`;
|
|
@@ -219,11 +220,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
219
220
|
"type": "number",
|
|
220
221
|
"description": "Right operand"
|
|
221
222
|
}
|
|
222
|
-
}
|
|
223
|
-
"required": [
|
|
224
|
-
"left",
|
|
225
|
-
"right"
|
|
226
|
-
]
|
|
223
|
+
}
|
|
227
224
|
}
|
|
228
225
|
},
|
|
229
226
|
{
|
|
@@ -265,10 +262,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
265
262
|
"type": "boolean",
|
|
266
263
|
"description": "Dry run flag"
|
|
267
264
|
}
|
|
268
|
-
}
|
|
269
|
-
"required": [
|
|
270
|
-
"title"
|
|
271
|
-
]
|
|
265
|
+
}
|
|
272
266
|
}
|
|
273
267
|
}
|
|
274
268
|
]"
|
|
@@ -325,11 +319,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
325
319
|
"type": "number",
|
|
326
320
|
"description": "Right operand"
|
|
327
321
|
}
|
|
328
|
-
}
|
|
329
|
-
"required": [
|
|
330
|
-
"left",
|
|
331
|
-
"right"
|
|
332
|
-
]
|
|
322
|
+
}
|
|
333
323
|
}
|
|
334
324
|
},
|
|
335
325
|
{
|
|
@@ -371,10 +361,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
371
361
|
"type": "boolean",
|
|
372
362
|
"description": "Dry run flag"
|
|
373
363
|
}
|
|
374
|
-
}
|
|
375
|
-
"required": [
|
|
376
|
-
"title"
|
|
377
|
-
]
|
|
364
|
+
}
|
|
378
365
|
}
|
|
379
366
|
}
|
|
380
367
|
]"
|
|
@@ -35,7 +35,9 @@ describe("createMcpAction", () => {
|
|
|
35
35
|
cli
|
|
36
36
|
.command("add", "Add numbers")
|
|
37
37
|
.option("--a <a>", z.number().describe("First"))
|
|
38
|
+
.required()
|
|
38
39
|
.option("--b <b>", z.number().describe("Second"))
|
|
40
|
+
.required()
|
|
39
41
|
.action((options) => ({ sum: options.a + options.b }));
|
|
40
42
|
|
|
41
43
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
@@ -238,7 +240,7 @@ describe("createMcpAction", () => {
|
|
|
238
240
|
expect(searchTool.description).toBe("Search for items");
|
|
239
241
|
expect(searchTool.inputSchema.properties).toHaveProperty("query");
|
|
240
242
|
expect(searchTool.inputSchema.properties).toHaveProperty("limit");
|
|
241
|
-
expect(searchTool.inputSchema.required).
|
|
243
|
+
expect(searchTool.inputSchema.required).toBeUndefined();
|
|
242
244
|
|
|
243
245
|
const deployTool = tools.tools.find((t) => t.name === "deploy")!;
|
|
244
246
|
expect(deployTool.inputSchema.properties).toHaveProperty("env");
|
|
@@ -292,6 +294,155 @@ describe("createMcpAction", () => {
|
|
|
292
294
|
}
|
|
293
295
|
});
|
|
294
296
|
|
|
297
|
+
it("keeps --flag <value> in properties but not in required", async () => {
|
|
298
|
+
const cli = goke("strada");
|
|
299
|
+
|
|
300
|
+
cli
|
|
301
|
+
.command("projects create <slug>", "Create a project")
|
|
302
|
+
.option("--traces-days <days>", z.string().optional().describe("Traces retention days"))
|
|
303
|
+
.option("--logs-days <days>", z.string().optional().describe("Logs retention days"))
|
|
304
|
+
.option("--errors-days <days>", z.string().optional().describe("Errors retention days"))
|
|
305
|
+
.option("--metrics-days <days>", z.string().optional().describe("Metrics retention days"))
|
|
306
|
+
.option("--all-days <days>", z.string().optional().describe("Retention for all signals"))
|
|
307
|
+
.action((slug, options) => ({ slug, ...options }));
|
|
308
|
+
|
|
309
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
310
|
+
|
|
311
|
+
cli.command("mcp", "Start MCP server").action(
|
|
312
|
+
createMcpAction({
|
|
313
|
+
cli,
|
|
314
|
+
createTransport: () => serverTransport,
|
|
315
|
+
}),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
cli.matchedCommandName = "mcp";
|
|
319
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp")!;
|
|
320
|
+
await mcpCommand.commandAction!({});
|
|
321
|
+
|
|
322
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
323
|
+
await client.connect(clientTransport);
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
const tools = await client.listTools();
|
|
327
|
+
const createTool = tools.tools.find((t) => t.name === "projects_create")!;
|
|
328
|
+
expect(createTool.inputSchema.properties).toHaveProperty("slug");
|
|
329
|
+
expect(createTool.inputSchema.properties).toHaveProperty("tracesDays");
|
|
330
|
+
expect(createTool.inputSchema.properties).toHaveProperty("logsDays");
|
|
331
|
+
expect(createTool.inputSchema.properties).toHaveProperty("allDays");
|
|
332
|
+
expect(createTool.inputSchema.required).toEqual(["slug"]);
|
|
333
|
+
|
|
334
|
+
const result = await client.callTool({
|
|
335
|
+
name: "projects_create",
|
|
336
|
+
arguments: { slug: "my-app" },
|
|
337
|
+
});
|
|
338
|
+
expect(firstTextContent(result)).toBe('{\n "slug": "my-app"\n}');
|
|
339
|
+
|
|
340
|
+
await expect(
|
|
341
|
+
client.callTool({
|
|
342
|
+
name: "projects_create",
|
|
343
|
+
arguments: {},
|
|
344
|
+
}),
|
|
345
|
+
).rejects.toThrow("Missing required argument: slug");
|
|
346
|
+
} finally {
|
|
347
|
+
await client.close();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("puts .required() --flag <value> in inputSchema.required", async () => {
|
|
352
|
+
const cli = goke("checks");
|
|
353
|
+
|
|
354
|
+
cli
|
|
355
|
+
.command("checks create", "Create a check")
|
|
356
|
+
.option("--url <url>", z.string().describe("URL to check"))
|
|
357
|
+
.required()
|
|
358
|
+
.option("--name <name>", z.string().describe("Check name"))
|
|
359
|
+
.required()
|
|
360
|
+
.option("--timeout [ms]", z.number().optional().describe("Timeout"))
|
|
361
|
+
.action((options) => options);
|
|
362
|
+
|
|
363
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
364
|
+
|
|
365
|
+
cli.command("mcp", "Start MCP server").action(
|
|
366
|
+
createMcpAction({
|
|
367
|
+
cli,
|
|
368
|
+
createTransport: () => serverTransport,
|
|
369
|
+
}),
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
cli.matchedCommandName = "mcp";
|
|
373
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp")!;
|
|
374
|
+
await mcpCommand.commandAction!({});
|
|
375
|
+
|
|
376
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
377
|
+
await client.connect(clientTransport);
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
const tools = await client.listTools();
|
|
381
|
+
const createTool = tools.tools.find((t) => t.name === "checks_create")!;
|
|
382
|
+
expect(createTool.inputSchema.properties).toHaveProperty("url");
|
|
383
|
+
expect(createTool.inputSchema.properties).toHaveProperty("name");
|
|
384
|
+
expect(createTool.inputSchema.properties).toHaveProperty("timeout");
|
|
385
|
+
expect(createTool.inputSchema.required).toEqual(["url", "name"]);
|
|
386
|
+
|
|
387
|
+
const result = await client.callTool({
|
|
388
|
+
name: "checks_create",
|
|
389
|
+
arguments: { url: "https://example.com", name: "home" },
|
|
390
|
+
});
|
|
391
|
+
expect(firstTextContent(result)).toContain("https://example.com");
|
|
392
|
+
|
|
393
|
+
await expect(
|
|
394
|
+
client.callTool({
|
|
395
|
+
name: "checks_create",
|
|
396
|
+
arguments: { name: "home" },
|
|
397
|
+
}),
|
|
398
|
+
).rejects.toThrow("Missing required argument: url");
|
|
399
|
+
} finally {
|
|
400
|
+
await client.close();
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("does not put wrapJsonSchema flags in required", async () => {
|
|
405
|
+
const cli = goke("wrapped");
|
|
406
|
+
|
|
407
|
+
cli
|
|
408
|
+
.command("config set", "Set a config value")
|
|
409
|
+
.option("--key <key>", wrapJsonSchema({ type: "string", description: "Config key" }))
|
|
410
|
+
.option("--value <value>", wrapJsonSchema({ type: "string", description: "Config value" }))
|
|
411
|
+
.action((options) => options);
|
|
412
|
+
|
|
413
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
414
|
+
|
|
415
|
+
cli.command("mcp", "Start MCP server").action(
|
|
416
|
+
createMcpAction({
|
|
417
|
+
cli,
|
|
418
|
+
createTransport: () => serverTransport,
|
|
419
|
+
}),
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
cli.matchedCommandName = "mcp";
|
|
423
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp")!;
|
|
424
|
+
await mcpCommand.commandAction!({});
|
|
425
|
+
|
|
426
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
427
|
+
await client.connect(clientTransport);
|
|
428
|
+
|
|
429
|
+
try {
|
|
430
|
+
const tools = await client.listTools();
|
|
431
|
+
const setTool = tools.tools.find((t) => t.name === "config_set")!;
|
|
432
|
+
expect(setTool.inputSchema.properties).toHaveProperty("key");
|
|
433
|
+
expect(setTool.inputSchema.properties).toHaveProperty("value");
|
|
434
|
+
expect(setTool.inputSchema.required).toBeUndefined();
|
|
435
|
+
|
|
436
|
+
const result = await client.callTool({
|
|
437
|
+
name: "config_set",
|
|
438
|
+
arguments: {},
|
|
439
|
+
});
|
|
440
|
+
expect(firstTextContent(result)).toBe("{}");
|
|
441
|
+
} finally {
|
|
442
|
+
await client.close();
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
295
446
|
it("returns empty tool list when only the mcp command exists", async () => {
|
|
296
447
|
const cli = goke("empty-app");
|
|
297
448
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Multi-tenant remote-MCP test.
|
|
3
3
|
*
|
|
4
|
-
* Proves that one goke cli exposed over
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Proves that one goke cli exposed over stateless MCP streamable HTTP
|
|
5
|
+
* can serve multiple concurrent users with fully isolated state
|
|
6
|
+
* (in-memory fs + cwd + env) — no shared host process stdio, no
|
|
7
|
+
* cross-tenant leaks, no mcp-session-id map. Isolation comes from
|
|
8
|
+
* the `x-tenant-id` header on each POST, not from `Mcp-Session-Id`.
|
|
8
9
|
*
|
|
9
10
|
* Wiring choices worth calling out:
|
|
10
11
|
*
|
|
@@ -14,12 +15,15 @@
|
|
|
14
15
|
* transport's `fetch` hook without ever binding a TCP socket
|
|
15
16
|
* or spinning up `node:http` / Express. Same wire protocol,
|
|
16
17
|
* zero sockets.
|
|
17
|
-
* - `
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* - `sessionIdGenerator: undefined` is stateless mode. The
|
|
19
|
+
* transport does not emit or expect `mcp-session-id`. Each
|
|
20
|
+
* POST builds a fresh Server + transport, then closes them.
|
|
21
|
+
* - `enableJsonResponse: true` switches the transport off SSE
|
|
22
|
+
* and into pure request/response JSON. GET SSE opens are
|
|
23
|
+
* answered with `405`, which the client treats as "server
|
|
24
|
+
* does not offer SSE" and moves on (see `_startOrAuthSse`
|
|
25
|
+
* in the SDK client).
|
|
26
|
+
* - Each request gets its own cli **clone** via
|
|
23
27
|
* `baseCli.clone({ cwd, env, fs })`. The clone inherits the
|
|
24
28
|
* command tree but owns its own `{ cwd, env, fs }`, which is
|
|
25
29
|
* what `runCliTool` forwards into every action through
|
|
@@ -100,6 +104,7 @@ function buildBaseCli(): Goke {
|
|
|
100
104
|
cli
|
|
101
105
|
.command("save <filename>", "Save content to a file in the tenant workspace")
|
|
102
106
|
.option("--content <content>", z.string().describe("File content"))
|
|
107
|
+
.required()
|
|
103
108
|
.action(async (filename: string, options: { content: string }, ctx) => {
|
|
104
109
|
const full = path.posix.join(ctx.process.cwd, filename);
|
|
105
110
|
await ctx.fs.writeFile(full, options.content);
|
|
@@ -120,8 +125,8 @@ function buildBaseCli(): Goke {
|
|
|
120
125
|
// ─── In-process multi-tenant fetch ────────────────────────────────
|
|
121
126
|
|
|
122
127
|
/**
|
|
123
|
-
* Per-tenant state resolved from the `x-tenant-id` header on
|
|
124
|
-
*
|
|
128
|
+
* Per-tenant state resolved from the `x-tenant-id` header on every
|
|
129
|
+
* POST. Each tenant gets its own cwd, env, and in-memory fs.
|
|
125
130
|
*/
|
|
126
131
|
interface TenantState {
|
|
127
132
|
cwd: string;
|
|
@@ -130,9 +135,10 @@ interface TenantState {
|
|
|
130
135
|
}
|
|
131
136
|
|
|
132
137
|
/**
|
|
133
|
-
* Build a `FetchLike` that
|
|
134
|
-
*
|
|
135
|
-
* from `x-tenant-id
|
|
138
|
+
* Build a `FetchLike` that handles each MCP POST with a fresh
|
|
139
|
+
* `WebStandardStreamableHTTPServerTransport` and cli clone.
|
|
140
|
+
* Tenant identity comes from `x-tenant-id` on every request.
|
|
141
|
+
* Nothing is keyed by `mcp-session-id`.
|
|
136
142
|
*/
|
|
137
143
|
function createMultiTenantFetch(options: {
|
|
138
144
|
baseCli: Goke;
|
|
@@ -152,18 +158,17 @@ function createMultiTenantFetch(options: {
|
|
|
152
158
|
sessionHeaders.push(sessionHeader);
|
|
153
159
|
}
|
|
154
160
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
161
|
+
const origin = headers.get("origin");
|
|
162
|
+
if (origin && origin !== "http://in-memory-mcp.test") {
|
|
163
|
+
return new Response(null, { status: 403 });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (method !== "POST") {
|
|
167
|
+
return new Response(null, { status: 405, headers: { Allow: "POST" } });
|
|
160
168
|
}
|
|
161
169
|
|
|
162
|
-
// Parse POST body once and hand it to the transport via
|
|
163
|
-
// `parsedBody` in `HandleRequestOptions` so we don't have to
|
|
164
|
-
// worry about Request body streams being single-use.
|
|
165
170
|
let parsedBody: unknown = undefined;
|
|
166
|
-
if (
|
|
171
|
+
if (init?.body != null) {
|
|
167
172
|
const rawBody = init.body;
|
|
168
173
|
const bodyText = typeof rawBody === "string"
|
|
169
174
|
? rawBody
|
|
@@ -202,10 +207,12 @@ function createMultiTenantFetch(options: {
|
|
|
202
207
|
});
|
|
203
208
|
|
|
204
209
|
await mcpServer.connect(transport);
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
210
|
+
try {
|
|
211
|
+
return await transport.handleRequest(request, { parsedBody });
|
|
212
|
+
} finally {
|
|
213
|
+
await transport.close();
|
|
214
|
+
await mcpServer.close();
|
|
215
|
+
}
|
|
209
216
|
};
|
|
210
217
|
|
|
211
218
|
return { fetch: customFetch, sessionHeaders };
|
|
@@ -237,8 +244,6 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
237
244
|
},
|
|
238
245
|
});
|
|
239
246
|
|
|
240
|
-
// The URL is a placeholder — the in-process fetch never looks
|
|
241
|
-
// at the host, just the method/headers/body.
|
|
242
247
|
const endpoint = new URL("http://in-memory-mcp.test/mcp");
|
|
243
248
|
|
|
244
249
|
async function connectTenant(tenantId: string): Promise<Client> {
|
|
@@ -273,16 +278,11 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
273
278
|
const bobClient = await connectTenant("tenant-b");
|
|
274
279
|
|
|
275
280
|
try {
|
|
276
|
-
// Each client sees the same tool catalog — it comes from the
|
|
277
|
-
// shared cli definition.
|
|
278
281
|
const aliceTools = (await aliceClient.listTools()).tools.map((t) => t.name).sort();
|
|
279
282
|
const bobTools = (await bobClient.listTools()).tools.map((t) => t.name).sort();
|
|
280
283
|
expect(aliceTools).toEqual(["load", "save"]);
|
|
281
284
|
expect(bobTools).toEqual(["load", "save"]);
|
|
282
285
|
|
|
283
|
-
// Both tenants write a file called `notes.txt` with different
|
|
284
|
-
// content. Since each request clones the cli with that tenant's
|
|
285
|
-
// cwd + fs, the writes land in separate Maps.
|
|
286
286
|
const aliceSave = await aliceClient.callTool({
|
|
287
287
|
name: "save",
|
|
288
288
|
arguments: { filename: "notes.txt", content: "alice-secret" },
|
|
@@ -297,7 +297,6 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
297
297
|
expect(firstTextBlock(bobSave)).toContain("/workspace-b/notes.txt");
|
|
298
298
|
expect(firstTextBlock(bobSave)).toContain("tenant-b");
|
|
299
299
|
|
|
300
|
-
// Each tenant reads back what it wrote.
|
|
301
300
|
const aliceLoad = await aliceClient.callTool({
|
|
302
301
|
name: "load",
|
|
303
302
|
arguments: { filename: "notes.txt" },
|
|
@@ -312,8 +311,6 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
312
311
|
expect(firstTextBlock(bobLoad)).toContain("bob-secret");
|
|
313
312
|
expect(firstTextBlock(bobLoad)).not.toContain("alice-secret");
|
|
314
313
|
|
|
315
|
-
// Sanity check: the underlying in-memory maps really are
|
|
316
|
-
// disjoint. Tenant A's fs only has tenant A's file.
|
|
317
314
|
const tenantAFs = tenants.get("tenant-a")!.fs;
|
|
318
315
|
const tenantBFs = tenants.get("tenant-b")!.fs;
|
|
319
316
|
expect([...tenantAFs.files.keys()]).toEqual(["/workspace-a/notes.txt"]);
|
|
@@ -342,4 +339,25 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
342
339
|
await bobClient.close();
|
|
343
340
|
}
|
|
344
341
|
});
|
|
342
|
+
|
|
343
|
+
it("rejects a present Origin that is not allowed", async () => {
|
|
344
|
+
const { fetch } = createMultiTenantFetch({
|
|
345
|
+
baseCli: buildBaseCli(),
|
|
346
|
+
resolveTenant: () => {
|
|
347
|
+
throw new Error("tenant must not be resolved for a forbidden origin");
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const response = await fetch("http://in-memory-mcp.test/mcp", {
|
|
352
|
+
method: "POST",
|
|
353
|
+
headers: {
|
|
354
|
+
origin: "https://evil.example",
|
|
355
|
+
"x-tenant-id": "tenant-a",
|
|
356
|
+
"content-type": "application/json",
|
|
357
|
+
},
|
|
358
|
+
body: "{}",
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
expect(response.status).toBe(403);
|
|
362
|
+
});
|
|
345
363
|
});
|
package/src/cli-to-mcp.ts
CHANGED
|
@@ -40,6 +40,7 @@ interface OptionLike {
|
|
|
40
40
|
description: string;
|
|
41
41
|
default?: unknown;
|
|
42
42
|
required?: boolean;
|
|
43
|
+
flagRequired?: boolean;
|
|
43
44
|
isBoolean?: boolean;
|
|
44
45
|
schema?: StandardJSONSchemaV1;
|
|
45
46
|
}
|
|
@@ -503,11 +504,13 @@ function createBinding(cli: Goke, command: Command, toolName: string): CliToolBi
|
|
|
503
504
|
}
|
|
504
505
|
}
|
|
505
506
|
|
|
507
|
+
// `--days <days>` means "value required if the flag is present". The flag
|
|
508
|
+
// itself is required only when `.required()` set `flagRequired`.
|
|
506
509
|
for (const option of options) {
|
|
507
510
|
const normalized = normalizeOptionSchema(option);
|
|
508
511
|
properties[option.name] = normalized.schema;
|
|
509
512
|
|
|
510
|
-
if (option.
|
|
513
|
+
if (option.flagRequired && option.default === undefined) {
|
|
511
514
|
requiredNames.push(option.name);
|
|
512
515
|
}
|
|
513
516
|
|