@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 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 (over `StreamableHTTPServerTransport`, SSE, or any other network transport), one server process handles many concurrent users. Each tool call must run against that user's own filesystem, working directory, environment, and stdin — otherwise tenants see each other's state and stdio writes trample the JSON-RPC channel.
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 HTTP POST, **clone** the cli with per-tenant `{ cwd, env, fs, stdin }` from the request auth (header, cookie, JWT). Mount it on a fresh `Server` via `addCliToolsToMcp({ cli: tenantClone, server })`.
203
- 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.
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
- Do **not** keep a `Map` of transports keyed by `Mcp-Session-Id`. Tenant state lives in durable storage. Each POST clones the cli from the request auth.
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
- let parsedBody: unknown
256
- if (request.method === "POST") {
257
- parsedBody = await request.clone().json().catch(() => undefined)
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 tool call on a request runs against `tenantCli`'s `cwd` / `env` / `fs`, not the base cli's and not another tenant's.
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).toEqual(["query"]);
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 the MCP streamable-HTTP
5
- * transport can serve multiple concurrent users with fully isolated
6
- * state (in-memory fs + cwd + env). Isolation comes from the
7
- * `x-tenant-id` header on each POST, not from `Mcp-Session-Id`.
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
- * - `enableJsonResponse: true` switches the transport off SSE and
18
- * into pure request/response JSON. GET SSE opens are answered
19
- * with `405`, which the client treats as "server does not offer
20
- * SSE" and moves on (see `_startOrAuthSse` in the SDK client).
21
- * - Each HTTP POST gets a fresh transport with
22
- * `sessionIdGenerator: undefined` and a cli **clone** via
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;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG"}
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 the MCP streamable-HTTP
5
- * transport can serve multiple concurrent users with fully isolated
6
- * state (in-memory fs + cwd + env). Isolation comes from the
7
- * `x-tenant-id` header on each POST, not from `Mcp-Session-Id`.
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
- * - `enableJsonResponse: true` switches the transport off SSE and
18
- * into pure request/response JSON. GET SSE opens are answered
19
- * with `405`, which the client treats as "server does not offer
20
- * SSE" and moves on (see `_startOrAuthSse` in the SDK client).
21
- * - Each HTTP POST gets a fresh transport with
22
- * `sessionIdGenerator: undefined` and a cli **clone** via
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 serves MCP streamable-HTTP traffic with a
105
- * fresh transport + cli clone on every POST. Tenant identity comes
106
- * from `x-tenant-id`. There is no `mcp-session-id` map.
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
- // Pure request/response mode: tell the client there's no SSE
119
- // available on GET. `_startOrAuthSse` in the SDK client treats
120
- // 405 as "server does not offer SSE" and moves on gracefully.
121
- if (method === "GET") {
122
- return new Response(null, { status: 405 });
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 (method === "POST" && init?.body != null) {
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
- const response = await transport.handleRequest(request, { parsedBody });
159
- await transport.close();
160
- await mcpServer.close();
161
- return response;
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
  });
@@ -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;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;AAqfD,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"}
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"}
@@ -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.required) {
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.0",
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.15.1"
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).toEqual(["query"]);
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 the MCP streamable-HTTP
5
- * transport can serve multiple concurrent users with fully isolated
6
- * state (in-memory fs + cwd + env). Isolation comes from the
7
- * `x-tenant-id` header on each POST, not from `Mcp-Session-Id`.
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
- * - `enableJsonResponse: true` switches the transport off SSE and
18
- * into pure request/response JSON. GET SSE opens are answered
19
- * with `405`, which the client treats as "server does not offer
20
- * SSE" and moves on (see `_startOrAuthSse` in the SDK client).
21
- * - Each HTTP POST gets a fresh transport with
22
- * `sessionIdGenerator: undefined` and a cli **clone** via
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 each
124
- * request. Each tenant gets its own cwd, env, and in-memory fs.
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 serves MCP streamable-HTTP traffic with a
134
- * fresh transport + cli clone on every POST. Tenant identity comes
135
- * from `x-tenant-id`. There is no `mcp-session-id` map.
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
- // Pure request/response mode: tell the client there's no SSE
156
- // available on GET. `_startOrAuthSse` in the SDK client treats
157
- // 405 as "server does not offer SSE" and moves on gracefully.
158
- if (method === "GET") {
159
- return new Response(null, { status: 405 });
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 (method === "POST" && init?.body != null) {
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
- const response = await transport.handleRequest(request, { parsedBody });
206
- await transport.close();
207
- await mcpServer.close();
208
- return response;
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.required) {
513
+ if (option.flagRequired && option.default === undefined) {
511
514
  requiredNames.push(option.name);
512
515
  }
513
516