@goke/mcp 0.1.0 → 0.1.1
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 +45 -10
- package/dist/__test__/add-cli-tools-to-mcp.test.js +4 -18
- package/dist/__test__/create-mcp-action.test.js +114 -0
- 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 +50 -36
- package/dist/cli-to-mcp.d.ts.map +1 -1
- package/dist/cli-to-mcp.js +7 -2
- package/package.json +2 -2
- package/src/__test__/add-cli-tools-to-mcp.test.ts +4 -18
- package/src/__test__/create-mcp-action.test.ts +147 -0
- package/src/__test__/http-multi-tenant.test.ts +55 -38
- package/src/cli-to-mcp.ts +9 -1
package/README.md
CHANGED
|
@@ -129,6 +129,8 @@ my-cli mcp
|
|
|
129
129
|
|
|
130
130
|
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
131
|
|
|
132
|
+
**`inputSchema.required` is not the CLI `<value>` syntax.** `--query <query>` means the flag needs a value if it is present. The flag is still optional unless the schema rejects omit (`z.string()`, not `z.string().optional()`). Required **positionals** like `<env>` always go in `required`. Schema-required flags go in `required` too. Untyped flags and `wrapJsonSchema()` flags stay optional.
|
|
133
|
+
|
|
132
134
|
### Installing the MCP server in clients
|
|
133
135
|
|
|
134
136
|
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 +196,15 @@ addCliToolsToMcp({ cli, server: mcp })
|
|
|
194
196
|
|
|
195
197
|
## Multi-tenant remote MCP over HTTP
|
|
196
198
|
|
|
197
|
-
When you expose a cli as a **remote** MCP
|
|
199
|
+
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.
|
|
200
|
+
|
|
201
|
+
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
202
|
|
|
199
203
|
The recipe is:
|
|
200
204
|
|
|
201
205
|
1. Define the cli **once**.
|
|
202
|
-
2. On every
|
|
203
|
-
3. Inside command actions, always use the **injected** `ctx`
|
|
206
|
+
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 })`.
|
|
207
|
+
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
208
|
|
|
205
209
|
### Write commands against `ctx`
|
|
206
210
|
|
|
@@ -235,7 +239,31 @@ cli
|
|
|
235
239
|
|
|
236
240
|
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
241
|
|
|
238
|
-
|
|
242
|
+
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.
|
|
243
|
+
|
|
244
|
+
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.
|
|
245
|
+
|
|
246
|
+
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.
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
POST /mcp (JWT / x-tenant-id)
|
|
250
|
+
│
|
|
251
|
+
v
|
|
252
|
+
resolveTenant(id) ──> baseCli.clone({ cwd, env, fs })
|
|
253
|
+
│
|
|
254
|
+
v
|
|
255
|
+
fresh Server + transport
|
|
256
|
+
sessionIdGenerator: undefined
|
|
257
|
+
│
|
|
258
|
+
v
|
|
259
|
+
addCliToolsToMcp ──> handleRequest
|
|
260
|
+
│
|
|
261
|
+
v
|
|
262
|
+
Response
|
|
263
|
+
│
|
|
264
|
+
v
|
|
265
|
+
close transport + server ──> nothing stored
|
|
266
|
+
```
|
|
239
267
|
|
|
240
268
|
You build one `handleMcpRequest(request: Request): Promise<Response>` function and mount it wherever you route HTTP:
|
|
241
269
|
|
|
@@ -251,12 +279,20 @@ declare function resolveTenant(tenantId: string): {
|
|
|
251
279
|
fs: GokeFs
|
|
252
280
|
}
|
|
253
281
|
|
|
282
|
+
declare function isAllowedOrigin(origin: string): boolean
|
|
283
|
+
|
|
254
284
|
export async function handleMcpRequest(request: Request): Promise<Response> {
|
|
255
|
-
|
|
256
|
-
if (
|
|
257
|
-
|
|
285
|
+
const origin = request.headers.get("origin")
|
|
286
|
+
if (origin && !isAllowedOrigin(origin)) {
|
|
287
|
+
return new Response(null, { status: 403 })
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (request.method !== "POST") {
|
|
291
|
+
return new Response(null, { status: 405, headers: { Allow: "POST" } })
|
|
258
292
|
}
|
|
259
293
|
|
|
294
|
+
const parsedBody = await request.clone().json().catch(() => undefined)
|
|
295
|
+
|
|
260
296
|
const tenantId = request.headers.get("x-tenant-id")
|
|
261
297
|
if (!tenantId) return new Response("missing x-tenant-id", { status: 401 })
|
|
262
298
|
const tenant = resolveTenant(tenantId)
|
|
@@ -277,7 +313,6 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
|
|
277
313
|
sessionIdGenerator: undefined,
|
|
278
314
|
enableJsonResponse: true,
|
|
279
315
|
})
|
|
280
|
-
|
|
281
316
|
await mcpServer.connect(transport)
|
|
282
317
|
try {
|
|
283
318
|
return await transport.handleRequest(request, { parsedBody })
|
|
@@ -320,7 +355,7 @@ export async function POST(request: Request) {
|
|
|
320
355
|
|
|
321
356
|
**Key guarantees**
|
|
322
357
|
|
|
323
|
-
- Every
|
|
358
|
+
- Every request runs against `tenantCli`'s `cwd` / `env` / `fs` — not the base cli's and not another tenant's.
|
|
324
359
|
- `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
360
|
- `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
361
|
- Actions that `throw` are caught and returned as `{ isError: true, content: [message, stderr] }`.
|
|
@@ -336,7 +371,7 @@ Only code that flows through `ctx` participates in the isolation. The following
|
|
|
336
371
|
|
|
337
372
|
Port those to `ctx.fs`, `ctx.console`, `ctx.process.*` and you're multi-tenant-safe.
|
|
338
373
|
|
|
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).
|
|
374
|
+
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
375
|
|
|
341
376
|
### Lower-level primitive: `cli.createExecutionContext(override)`
|
|
342
377
|
|
|
@@ -178,11 +178,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
178
178
|
"type": "number",
|
|
179
179
|
"description": "Right operand"
|
|
180
180
|
}
|
|
181
|
-
}
|
|
182
|
-
"required": [
|
|
183
|
-
"left",
|
|
184
|
-
"right"
|
|
185
|
-
]
|
|
181
|
+
}
|
|
186
182
|
}
|
|
187
183
|
},
|
|
188
184
|
{
|
|
@@ -224,10 +220,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
224
220
|
"type": "boolean",
|
|
225
221
|
"description": "Dry run flag"
|
|
226
222
|
}
|
|
227
|
-
}
|
|
228
|
-
"required": [
|
|
229
|
-
"title"
|
|
230
|
-
]
|
|
223
|
+
}
|
|
231
224
|
}
|
|
232
225
|
}
|
|
233
226
|
]"
|
|
@@ -280,11 +273,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
280
273
|
"type": "number",
|
|
281
274
|
"description": "Right operand"
|
|
282
275
|
}
|
|
283
|
-
}
|
|
284
|
-
"required": [
|
|
285
|
-
"left",
|
|
286
|
-
"right"
|
|
287
|
-
]
|
|
276
|
+
}
|
|
288
277
|
}
|
|
289
278
|
},
|
|
290
279
|
{
|
|
@@ -326,10 +315,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
326
315
|
"type": "boolean",
|
|
327
316
|
"description": "Dry run flag"
|
|
328
317
|
}
|
|
329
|
-
}
|
|
330
|
-
"required": [
|
|
331
|
-
"title"
|
|
332
|
-
]
|
|
318
|
+
}
|
|
333
319
|
}
|
|
334
320
|
}
|
|
335
321
|
]"
|
|
@@ -229,6 +229,120 @@ describe("createMcpAction", () => {
|
|
|
229
229
|
await client.close();
|
|
230
230
|
}
|
|
231
231
|
});
|
|
232
|
+
it("keeps --flag <value> in properties but not in required", async () => {
|
|
233
|
+
const cli = goke("strada");
|
|
234
|
+
cli
|
|
235
|
+
.command("projects create <slug>", "Create a project")
|
|
236
|
+
.option("--traces-days <days>", z.string().optional().describe("Traces retention days"))
|
|
237
|
+
.option("--logs-days <days>", z.string().optional().describe("Logs retention days"))
|
|
238
|
+
.option("--errors-days <days>", z.string().optional().describe("Errors retention days"))
|
|
239
|
+
.option("--metrics-days <days>", z.string().optional().describe("Metrics retention days"))
|
|
240
|
+
.option("--all-days <days>", z.string().optional().describe("Retention for all signals"))
|
|
241
|
+
.action((slug, options) => ({ slug, ...options }));
|
|
242
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
243
|
+
cli.command("mcp", "Start MCP server").action(createMcpAction({
|
|
244
|
+
cli,
|
|
245
|
+
createTransport: () => serverTransport,
|
|
246
|
+
}));
|
|
247
|
+
cli.matchedCommandName = "mcp";
|
|
248
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp");
|
|
249
|
+
await mcpCommand.commandAction({});
|
|
250
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
251
|
+
await client.connect(clientTransport);
|
|
252
|
+
try {
|
|
253
|
+
const tools = await client.listTools();
|
|
254
|
+
const createTool = tools.tools.find((t) => t.name === "projects_create");
|
|
255
|
+
expect(createTool.inputSchema.properties).toHaveProperty("slug");
|
|
256
|
+
expect(createTool.inputSchema.properties).toHaveProperty("tracesDays");
|
|
257
|
+
expect(createTool.inputSchema.properties).toHaveProperty("logsDays");
|
|
258
|
+
expect(createTool.inputSchema.properties).toHaveProperty("allDays");
|
|
259
|
+
expect(createTool.inputSchema.required).toEqual(["slug"]);
|
|
260
|
+
const result = await client.callTool({
|
|
261
|
+
name: "projects_create",
|
|
262
|
+
arguments: { slug: "my-app" },
|
|
263
|
+
});
|
|
264
|
+
expect(firstTextContent(result)).toBe('{\n "slug": "my-app"\n}');
|
|
265
|
+
await expect(client.callTool({
|
|
266
|
+
name: "projects_create",
|
|
267
|
+
arguments: {},
|
|
268
|
+
})).rejects.toThrow("Missing required argument: slug");
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
await client.close();
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
it("puts schema-required --flag <value> in inputSchema.required", async () => {
|
|
275
|
+
const cli = goke("checks");
|
|
276
|
+
cli
|
|
277
|
+
.command("checks create", "Create a check")
|
|
278
|
+
.option("--url <url>", z.string().describe("URL to check"))
|
|
279
|
+
.option("--name <name>", z.string().describe("Check name"))
|
|
280
|
+
.option("--timeout [ms]", z.number().optional().describe("Timeout"))
|
|
281
|
+
.action((options) => options);
|
|
282
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
283
|
+
cli.command("mcp", "Start MCP server").action(createMcpAction({
|
|
284
|
+
cli,
|
|
285
|
+
createTransport: () => serverTransport,
|
|
286
|
+
}));
|
|
287
|
+
cli.matchedCommandName = "mcp";
|
|
288
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp");
|
|
289
|
+
await mcpCommand.commandAction({});
|
|
290
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
291
|
+
await client.connect(clientTransport);
|
|
292
|
+
try {
|
|
293
|
+
const tools = await client.listTools();
|
|
294
|
+
const createTool = tools.tools.find((t) => t.name === "checks_create");
|
|
295
|
+
expect(createTool.inputSchema.properties).toHaveProperty("url");
|
|
296
|
+
expect(createTool.inputSchema.properties).toHaveProperty("name");
|
|
297
|
+
expect(createTool.inputSchema.properties).toHaveProperty("timeout");
|
|
298
|
+
expect(createTool.inputSchema.required).toEqual(["url", "name"]);
|
|
299
|
+
const result = await client.callTool({
|
|
300
|
+
name: "checks_create",
|
|
301
|
+
arguments: { url: "https://example.com", name: "home" },
|
|
302
|
+
});
|
|
303
|
+
expect(firstTextContent(result)).toContain("https://example.com");
|
|
304
|
+
await expect(client.callTool({
|
|
305
|
+
name: "checks_create",
|
|
306
|
+
arguments: { name: "home" },
|
|
307
|
+
})).rejects.toThrow("Missing required argument: url");
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
await client.close();
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
it("does not put wrapJsonSchema flags in required", async () => {
|
|
314
|
+
const cli = goke("wrapped");
|
|
315
|
+
cli
|
|
316
|
+
.command("config set", "Set a config value")
|
|
317
|
+
.option("--key <key>", wrapJsonSchema({ type: "string", description: "Config key" }))
|
|
318
|
+
.option("--value <value>", wrapJsonSchema({ type: "string", description: "Config value" }))
|
|
319
|
+
.action((options) => options);
|
|
320
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
321
|
+
cli.command("mcp", "Start MCP server").action(createMcpAction({
|
|
322
|
+
cli,
|
|
323
|
+
createTransport: () => serverTransport,
|
|
324
|
+
}));
|
|
325
|
+
cli.matchedCommandName = "mcp";
|
|
326
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp");
|
|
327
|
+
await mcpCommand.commandAction({});
|
|
328
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
329
|
+
await client.connect(clientTransport);
|
|
330
|
+
try {
|
|
331
|
+
const tools = await client.listTools();
|
|
332
|
+
const setTool = tools.tools.find((t) => t.name === "config_set");
|
|
333
|
+
expect(setTool.inputSchema.properties).toHaveProperty("key");
|
|
334
|
+
expect(setTool.inputSchema.properties).toHaveProperty("value");
|
|
335
|
+
expect(setTool.inputSchema.required).toBeUndefined();
|
|
336
|
+
const result = await client.callTool({
|
|
337
|
+
name: "config_set",
|
|
338
|
+
arguments: {},
|
|
339
|
+
});
|
|
340
|
+
expect(firstTextContent(result)).toBe("{}");
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
await client.close();
|
|
344
|
+
}
|
|
345
|
+
});
|
|
232
346
|
it("returns empty tool list when only the mcp command exists", async () => {
|
|
233
347
|
const cli = goke("empty-app");
|
|
234
348
|
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
|
|
@@ -101,9 +105,10 @@ function buildBaseCli() {
|
|
|
101
105
|
return cli;
|
|
102
106
|
}
|
|
103
107
|
/**
|
|
104
|
-
* Build a `FetchLike` that
|
|
105
|
-
*
|
|
106
|
-
* from `x-tenant-id
|
|
108
|
+
* Build a `FetchLike` that handles each MCP POST with a fresh
|
|
109
|
+
* `WebStandardStreamableHTTPServerTransport` and cli clone.
|
|
110
|
+
* Tenant identity comes from `x-tenant-id` on every request.
|
|
111
|
+
* Nothing is keyed by `mcp-session-id`.
|
|
107
112
|
*/
|
|
108
113
|
function createMultiTenantFetch(options) {
|
|
109
114
|
const { baseCli, resolveTenant } = options;
|
|
@@ -115,17 +120,15 @@ function createMultiTenantFetch(options) {
|
|
|
115
120
|
if (sessionHeader) {
|
|
116
121
|
sessionHeaders.push(sessionHeader);
|
|
117
122
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
+
const origin = headers.get("origin");
|
|
124
|
+
if (origin && origin !== "http://in-memory-mcp.test") {
|
|
125
|
+
return new Response(null, { status: 403 });
|
|
126
|
+
}
|
|
127
|
+
if (method !== "POST") {
|
|
128
|
+
return new Response(null, { status: 405, headers: { Allow: "POST" } });
|
|
123
129
|
}
|
|
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
130
|
let parsedBody = undefined;
|
|
128
|
-
if (
|
|
131
|
+
if (init?.body != null) {
|
|
129
132
|
const rawBody = init.body;
|
|
130
133
|
const bodyText = typeof rawBody === "string"
|
|
131
134
|
? rawBody
|
|
@@ -155,10 +158,13 @@ function createMultiTenantFetch(options) {
|
|
|
155
158
|
enableJsonResponse: true,
|
|
156
159
|
});
|
|
157
160
|
await mcpServer.connect(transport);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
161
|
+
try {
|
|
162
|
+
return await transport.handleRequest(request, { parsedBody });
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
await transport.close();
|
|
166
|
+
await mcpServer.close();
|
|
167
|
+
}
|
|
162
168
|
};
|
|
163
169
|
return { fetch: customFetch, sessionHeaders };
|
|
164
170
|
}
|
|
@@ -186,8 +192,6 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
186
192
|
return tenant;
|
|
187
193
|
},
|
|
188
194
|
});
|
|
189
|
-
// The URL is a placeholder — the in-process fetch never looks
|
|
190
|
-
// at the host, just the method/headers/body.
|
|
191
195
|
const endpoint = new URL("http://in-memory-mcp.test/mcp");
|
|
192
196
|
async function connectTenant(tenantId) {
|
|
193
197
|
const transport = new StreamableHTTPClientTransport(endpoint, {
|
|
@@ -213,15 +217,10 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
213
217
|
const aliceClient = await connectTenant("tenant-a");
|
|
214
218
|
const bobClient = await connectTenant("tenant-b");
|
|
215
219
|
try {
|
|
216
|
-
// Each client sees the same tool catalog — it comes from the
|
|
217
|
-
// shared cli definition.
|
|
218
220
|
const aliceTools = (await aliceClient.listTools()).tools.map((t) => t.name).sort();
|
|
219
221
|
const bobTools = (await bobClient.listTools()).tools.map((t) => t.name).sort();
|
|
220
222
|
expect(aliceTools).toEqual(["load", "save"]);
|
|
221
223
|
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
224
|
const aliceSave = await aliceClient.callTool({
|
|
226
225
|
name: "save",
|
|
227
226
|
arguments: { filename: "notes.txt", content: "alice-secret" },
|
|
@@ -234,7 +233,6 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
234
233
|
expect(firstTextBlock(aliceSave)).toContain("tenant-a");
|
|
235
234
|
expect(firstTextBlock(bobSave)).toContain("/workspace-b/notes.txt");
|
|
236
235
|
expect(firstTextBlock(bobSave)).toContain("tenant-b");
|
|
237
|
-
// Each tenant reads back what it wrote.
|
|
238
236
|
const aliceLoad = await aliceClient.callTool({
|
|
239
237
|
name: "load",
|
|
240
238
|
arguments: { filename: "notes.txt" },
|
|
@@ -247,8 +245,6 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
247
245
|
expect(firstTextBlock(aliceLoad)).not.toContain("bob-secret");
|
|
248
246
|
expect(firstTextBlock(bobLoad)).toContain("bob-secret");
|
|
249
247
|
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
248
|
const tenantAFs = tenants.get("tenant-a").fs;
|
|
253
249
|
const tenantBFs = tenants.get("tenant-b").fs;
|
|
254
250
|
expect([...tenantAFs.files.keys()]).toEqual(["/workspace-a/notes.txt"]);
|
|
@@ -277,4 +273,22 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
277
273
|
await bobClient.close();
|
|
278
274
|
}
|
|
279
275
|
});
|
|
276
|
+
it("rejects a present Origin that is not allowed", async () => {
|
|
277
|
+
const { fetch } = createMultiTenantFetch({
|
|
278
|
+
baseCli: buildBaseCli(),
|
|
279
|
+
resolveTenant: () => {
|
|
280
|
+
throw new Error("tenant must not be resolved for a forbidden origin");
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
const response = await fetch("http://in-memory-mcp.test/mcp", {
|
|
284
|
+
method: "POST",
|
|
285
|
+
headers: {
|
|
286
|
+
origin: "https://evil.example",
|
|
287
|
+
"x-tenant-id": "tenant-a",
|
|
288
|
+
"content-type": "application/json",
|
|
289
|
+
},
|
|
290
|
+
body: "{}",
|
|
291
|
+
});
|
|
292
|
+
expect(response.status).toBe(403);
|
|
293
|
+
});
|
|
280
294
|
});
|
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,
|
|
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,EAML,KAAK,IAAI,EAIV,MAAM,MAAM,CAAC;AAwFd,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;AA4fD,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
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* or a high-level McpServer by mounting tools/list + tools/call handlers.
|
|
6
6
|
*/
|
|
7
7
|
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
-
import { coerceBySchema, extractJsonSchema, GokeProcessExit, } from "goke";
|
|
8
|
+
import { coerceBySchema, extractJsonSchema, GokeProcessExit, schemaAcceptsOmittedValue, } from "goke";
|
|
9
9
|
const CLI_TO_MCP_STATE = Symbol.for("@goke/mcp/cli-to-mcp-state");
|
|
10
10
|
function createTextCaptureStream() {
|
|
11
11
|
const chunks = [];
|
|
@@ -359,10 +359,15 @@ 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 a non-optional schema rejects `undefined`.
|
|
362
364
|
for (const option of options) {
|
|
363
365
|
const normalized = normalizeOptionSchema(option);
|
|
364
366
|
properties[option.name] = normalized.schema;
|
|
365
|
-
if (option.required
|
|
367
|
+
if (option.required
|
|
368
|
+
&& option.default === undefined
|
|
369
|
+
&& option.schema
|
|
370
|
+
&& !schemaAcceptsOmittedValue(option.schema)) {
|
|
366
371
|
requiredNames.push(option.name);
|
|
367
372
|
}
|
|
368
373
|
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.1",
|
|
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.16.0"
|
|
55
55
|
},
|
|
56
56
|
"scripts": {
|
|
57
57
|
"clean": "rm -rf dist",
|
|
@@ -219,11 +219,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
219
219
|
"type": "number",
|
|
220
220
|
"description": "Right operand"
|
|
221
221
|
}
|
|
222
|
-
}
|
|
223
|
-
"required": [
|
|
224
|
-
"left",
|
|
225
|
-
"right"
|
|
226
|
-
]
|
|
222
|
+
}
|
|
227
223
|
}
|
|
228
224
|
},
|
|
229
225
|
{
|
|
@@ -265,10 +261,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
265
261
|
"type": "boolean",
|
|
266
262
|
"description": "Dry run flag"
|
|
267
263
|
}
|
|
268
|
-
}
|
|
269
|
-
"required": [
|
|
270
|
-
"title"
|
|
271
|
-
]
|
|
264
|
+
}
|
|
272
265
|
}
|
|
273
266
|
}
|
|
274
267
|
]"
|
|
@@ -325,11 +318,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
325
318
|
"type": "number",
|
|
326
319
|
"description": "Right operand"
|
|
327
320
|
}
|
|
328
|
-
}
|
|
329
|
-
"required": [
|
|
330
|
-
"left",
|
|
331
|
-
"right"
|
|
332
|
-
]
|
|
321
|
+
}
|
|
333
322
|
}
|
|
334
323
|
},
|
|
335
324
|
{
|
|
@@ -371,10 +360,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
371
360
|
"type": "boolean",
|
|
372
361
|
"description": "Dry run flag"
|
|
373
362
|
}
|
|
374
|
-
}
|
|
375
|
-
"required": [
|
|
376
|
-
"title"
|
|
377
|
-
]
|
|
363
|
+
}
|
|
378
364
|
}
|
|
379
365
|
}
|
|
380
366
|
]"
|
|
@@ -292,6 +292,153 @@ describe("createMcpAction", () => {
|
|
|
292
292
|
}
|
|
293
293
|
});
|
|
294
294
|
|
|
295
|
+
it("keeps --flag <value> in properties but not in required", async () => {
|
|
296
|
+
const cli = goke("strada");
|
|
297
|
+
|
|
298
|
+
cli
|
|
299
|
+
.command("projects create <slug>", "Create a project")
|
|
300
|
+
.option("--traces-days <days>", z.string().optional().describe("Traces retention days"))
|
|
301
|
+
.option("--logs-days <days>", z.string().optional().describe("Logs retention days"))
|
|
302
|
+
.option("--errors-days <days>", z.string().optional().describe("Errors retention days"))
|
|
303
|
+
.option("--metrics-days <days>", z.string().optional().describe("Metrics retention days"))
|
|
304
|
+
.option("--all-days <days>", z.string().optional().describe("Retention for all signals"))
|
|
305
|
+
.action((slug, options) => ({ slug, ...options }));
|
|
306
|
+
|
|
307
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
308
|
+
|
|
309
|
+
cli.command("mcp", "Start MCP server").action(
|
|
310
|
+
createMcpAction({
|
|
311
|
+
cli,
|
|
312
|
+
createTransport: () => serverTransport,
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
cli.matchedCommandName = "mcp";
|
|
317
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp")!;
|
|
318
|
+
await mcpCommand.commandAction!({});
|
|
319
|
+
|
|
320
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
321
|
+
await client.connect(clientTransport);
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
const tools = await client.listTools();
|
|
325
|
+
const createTool = tools.tools.find((t) => t.name === "projects_create")!;
|
|
326
|
+
expect(createTool.inputSchema.properties).toHaveProperty("slug");
|
|
327
|
+
expect(createTool.inputSchema.properties).toHaveProperty("tracesDays");
|
|
328
|
+
expect(createTool.inputSchema.properties).toHaveProperty("logsDays");
|
|
329
|
+
expect(createTool.inputSchema.properties).toHaveProperty("allDays");
|
|
330
|
+
expect(createTool.inputSchema.required).toEqual(["slug"]);
|
|
331
|
+
|
|
332
|
+
const result = await client.callTool({
|
|
333
|
+
name: "projects_create",
|
|
334
|
+
arguments: { slug: "my-app" },
|
|
335
|
+
});
|
|
336
|
+
expect(firstTextContent(result)).toBe('{\n "slug": "my-app"\n}');
|
|
337
|
+
|
|
338
|
+
await expect(
|
|
339
|
+
client.callTool({
|
|
340
|
+
name: "projects_create",
|
|
341
|
+
arguments: {},
|
|
342
|
+
}),
|
|
343
|
+
).rejects.toThrow("Missing required argument: slug");
|
|
344
|
+
} finally {
|
|
345
|
+
await client.close();
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("puts schema-required --flag <value> in inputSchema.required", async () => {
|
|
350
|
+
const cli = goke("checks");
|
|
351
|
+
|
|
352
|
+
cli
|
|
353
|
+
.command("checks create", "Create a check")
|
|
354
|
+
.option("--url <url>", z.string().describe("URL to check"))
|
|
355
|
+
.option("--name <name>", z.string().describe("Check name"))
|
|
356
|
+
.option("--timeout [ms]", z.number().optional().describe("Timeout"))
|
|
357
|
+
.action((options) => options);
|
|
358
|
+
|
|
359
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
360
|
+
|
|
361
|
+
cli.command("mcp", "Start MCP server").action(
|
|
362
|
+
createMcpAction({
|
|
363
|
+
cli,
|
|
364
|
+
createTransport: () => serverTransport,
|
|
365
|
+
}),
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
cli.matchedCommandName = "mcp";
|
|
369
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp")!;
|
|
370
|
+
await mcpCommand.commandAction!({});
|
|
371
|
+
|
|
372
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
373
|
+
await client.connect(clientTransport);
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
const tools = await client.listTools();
|
|
377
|
+
const createTool = tools.tools.find((t) => t.name === "checks_create")!;
|
|
378
|
+
expect(createTool.inputSchema.properties).toHaveProperty("url");
|
|
379
|
+
expect(createTool.inputSchema.properties).toHaveProperty("name");
|
|
380
|
+
expect(createTool.inputSchema.properties).toHaveProperty("timeout");
|
|
381
|
+
expect(createTool.inputSchema.required).toEqual(["url", "name"]);
|
|
382
|
+
|
|
383
|
+
const result = await client.callTool({
|
|
384
|
+
name: "checks_create",
|
|
385
|
+
arguments: { url: "https://example.com", name: "home" },
|
|
386
|
+
});
|
|
387
|
+
expect(firstTextContent(result)).toContain("https://example.com");
|
|
388
|
+
|
|
389
|
+
await expect(
|
|
390
|
+
client.callTool({
|
|
391
|
+
name: "checks_create",
|
|
392
|
+
arguments: { name: "home" },
|
|
393
|
+
}),
|
|
394
|
+
).rejects.toThrow("Missing required argument: url");
|
|
395
|
+
} finally {
|
|
396
|
+
await client.close();
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("does not put wrapJsonSchema flags in required", async () => {
|
|
401
|
+
const cli = goke("wrapped");
|
|
402
|
+
|
|
403
|
+
cli
|
|
404
|
+
.command("config set", "Set a config value")
|
|
405
|
+
.option("--key <key>", wrapJsonSchema({ type: "string", description: "Config key" }))
|
|
406
|
+
.option("--value <value>", wrapJsonSchema({ type: "string", description: "Config value" }))
|
|
407
|
+
.action((options) => options);
|
|
408
|
+
|
|
409
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
410
|
+
|
|
411
|
+
cli.command("mcp", "Start MCP server").action(
|
|
412
|
+
createMcpAction({
|
|
413
|
+
cli,
|
|
414
|
+
createTransport: () => serverTransport,
|
|
415
|
+
}),
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
cli.matchedCommandName = "mcp";
|
|
419
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp")!;
|
|
420
|
+
await mcpCommand.commandAction!({});
|
|
421
|
+
|
|
422
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
423
|
+
await client.connect(clientTransport);
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
const tools = await client.listTools();
|
|
427
|
+
const setTool = tools.tools.find((t) => t.name === "config_set")!;
|
|
428
|
+
expect(setTool.inputSchema.properties).toHaveProperty("key");
|
|
429
|
+
expect(setTool.inputSchema.properties).toHaveProperty("value");
|
|
430
|
+
expect(setTool.inputSchema.required).toBeUndefined();
|
|
431
|
+
|
|
432
|
+
const result = await client.callTool({
|
|
433
|
+
name: "config_set",
|
|
434
|
+
arguments: {},
|
|
435
|
+
});
|
|
436
|
+
expect(firstTextContent(result)).toBe("{}");
|
|
437
|
+
} finally {
|
|
438
|
+
await client.close();
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
295
442
|
it("returns empty tool list when only the mcp command exists", async () => {
|
|
296
443
|
const cli = goke("empty-app");
|
|
297
444
|
|
|
@@ -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
|
|
@@ -120,8 +124,8 @@ function buildBaseCli(): Goke {
|
|
|
120
124
|
// ─── In-process multi-tenant fetch ────────────────────────────────
|
|
121
125
|
|
|
122
126
|
/**
|
|
123
|
-
* Per-tenant state resolved from the `x-tenant-id` header on
|
|
124
|
-
*
|
|
127
|
+
* Per-tenant state resolved from the `x-tenant-id` header on every
|
|
128
|
+
* POST. Each tenant gets its own cwd, env, and in-memory fs.
|
|
125
129
|
*/
|
|
126
130
|
interface TenantState {
|
|
127
131
|
cwd: string;
|
|
@@ -130,9 +134,10 @@ interface TenantState {
|
|
|
130
134
|
}
|
|
131
135
|
|
|
132
136
|
/**
|
|
133
|
-
* Build a `FetchLike` that
|
|
134
|
-
*
|
|
135
|
-
* from `x-tenant-id
|
|
137
|
+
* Build a `FetchLike` that handles each MCP POST with a fresh
|
|
138
|
+
* `WebStandardStreamableHTTPServerTransport` and cli clone.
|
|
139
|
+
* Tenant identity comes from `x-tenant-id` on every request.
|
|
140
|
+
* Nothing is keyed by `mcp-session-id`.
|
|
136
141
|
*/
|
|
137
142
|
function createMultiTenantFetch(options: {
|
|
138
143
|
baseCli: Goke;
|
|
@@ -152,18 +157,17 @@ function createMultiTenantFetch(options: {
|
|
|
152
157
|
sessionHeaders.push(sessionHeader);
|
|
153
158
|
}
|
|
154
159
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
+
const origin = headers.get("origin");
|
|
161
|
+
if (origin && origin !== "http://in-memory-mcp.test") {
|
|
162
|
+
return new Response(null, { status: 403 });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (method !== "POST") {
|
|
166
|
+
return new Response(null, { status: 405, headers: { Allow: "POST" } });
|
|
160
167
|
}
|
|
161
168
|
|
|
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
169
|
let parsedBody: unknown = undefined;
|
|
166
|
-
if (
|
|
170
|
+
if (init?.body != null) {
|
|
167
171
|
const rawBody = init.body;
|
|
168
172
|
const bodyText = typeof rawBody === "string"
|
|
169
173
|
? rawBody
|
|
@@ -202,10 +206,12 @@ function createMultiTenantFetch(options: {
|
|
|
202
206
|
});
|
|
203
207
|
|
|
204
208
|
await mcpServer.connect(transport);
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
+
try {
|
|
210
|
+
return await transport.handleRequest(request, { parsedBody });
|
|
211
|
+
} finally {
|
|
212
|
+
await transport.close();
|
|
213
|
+
await mcpServer.close();
|
|
214
|
+
}
|
|
209
215
|
};
|
|
210
216
|
|
|
211
217
|
return { fetch: customFetch, sessionHeaders };
|
|
@@ -237,8 +243,6 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
237
243
|
},
|
|
238
244
|
});
|
|
239
245
|
|
|
240
|
-
// The URL is a placeholder — the in-process fetch never looks
|
|
241
|
-
// at the host, just the method/headers/body.
|
|
242
246
|
const endpoint = new URL("http://in-memory-mcp.test/mcp");
|
|
243
247
|
|
|
244
248
|
async function connectTenant(tenantId: string): Promise<Client> {
|
|
@@ -273,16 +277,11 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
273
277
|
const bobClient = await connectTenant("tenant-b");
|
|
274
278
|
|
|
275
279
|
try {
|
|
276
|
-
// Each client sees the same tool catalog — it comes from the
|
|
277
|
-
// shared cli definition.
|
|
278
280
|
const aliceTools = (await aliceClient.listTools()).tools.map((t) => t.name).sort();
|
|
279
281
|
const bobTools = (await bobClient.listTools()).tools.map((t) => t.name).sort();
|
|
280
282
|
expect(aliceTools).toEqual(["load", "save"]);
|
|
281
283
|
expect(bobTools).toEqual(["load", "save"]);
|
|
282
284
|
|
|
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
285
|
const aliceSave = await aliceClient.callTool({
|
|
287
286
|
name: "save",
|
|
288
287
|
arguments: { filename: "notes.txt", content: "alice-secret" },
|
|
@@ -297,7 +296,6 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
297
296
|
expect(firstTextBlock(bobSave)).toContain("/workspace-b/notes.txt");
|
|
298
297
|
expect(firstTextBlock(bobSave)).toContain("tenant-b");
|
|
299
298
|
|
|
300
|
-
// Each tenant reads back what it wrote.
|
|
301
299
|
const aliceLoad = await aliceClient.callTool({
|
|
302
300
|
name: "load",
|
|
303
301
|
arguments: { filename: "notes.txt" },
|
|
@@ -312,8 +310,6 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
312
310
|
expect(firstTextBlock(bobLoad)).toContain("bob-secret");
|
|
313
311
|
expect(firstTextBlock(bobLoad)).not.toContain("alice-secret");
|
|
314
312
|
|
|
315
|
-
// Sanity check: the underlying in-memory maps really are
|
|
316
|
-
// disjoint. Tenant A's fs only has tenant A's file.
|
|
317
313
|
const tenantAFs = tenants.get("tenant-a")!.fs;
|
|
318
314
|
const tenantBFs = tenants.get("tenant-b")!.fs;
|
|
319
315
|
expect([...tenantAFs.files.keys()]).toEqual(["/workspace-a/notes.txt"]);
|
|
@@ -342,4 +338,25 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
342
338
|
await bobClient.close();
|
|
343
339
|
}
|
|
344
340
|
});
|
|
341
|
+
|
|
342
|
+
it("rejects a present Origin that is not allowed", async () => {
|
|
343
|
+
const { fetch } = createMultiTenantFetch({
|
|
344
|
+
baseCli: buildBaseCli(),
|
|
345
|
+
resolveTenant: () => {
|
|
346
|
+
throw new Error("tenant must not be resolved for a forbidden origin");
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const response = await fetch("http://in-memory-mcp.test/mcp", {
|
|
351
|
+
method: "POST",
|
|
352
|
+
headers: {
|
|
353
|
+
origin: "https://evil.example",
|
|
354
|
+
"x-tenant-id": "tenant-a",
|
|
355
|
+
"content-type": "application/json",
|
|
356
|
+
},
|
|
357
|
+
body: "{}",
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
expect(response.status).toBe(403);
|
|
361
|
+
});
|
|
345
362
|
});
|
package/src/cli-to-mcp.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
coerceBySchema,
|
|
21
21
|
extractJsonSchema,
|
|
22
22
|
GokeProcessExit,
|
|
23
|
+
schemaAcceptsOmittedValue,
|
|
23
24
|
type Command,
|
|
24
25
|
type Goke,
|
|
25
26
|
type GokeExecutionContext,
|
|
@@ -503,11 +504,18 @@ 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 a non-optional schema rejects `undefined`.
|
|
506
509
|
for (const option of options) {
|
|
507
510
|
const normalized = normalizeOptionSchema(option);
|
|
508
511
|
properties[option.name] = normalized.schema;
|
|
509
512
|
|
|
510
|
-
if (
|
|
513
|
+
if (
|
|
514
|
+
option.required
|
|
515
|
+
&& option.default === undefined
|
|
516
|
+
&& option.schema
|
|
517
|
+
&& !schemaAcceptsOmittedValue(option.schema)
|
|
518
|
+
) {
|
|
511
519
|
requiredNames.push(option.name);
|
|
512
520
|
}
|
|
513
521
|
|