@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 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 (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.
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 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.
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
- 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.
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
- let parsedBody: unknown
256
- if (request.method === "POST") {
257
- parsedBody = await request.clone().json().catch(() => undefined)
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 tool call on a request runs against `tenantCli`'s `cwd` / `env` / `fs`, not the base cli's and not another tenant's.
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 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
@@ -101,9 +105,10 @@ function buildBaseCli() {
101
105
  return cli;
102
106
  }
103
107
  /**
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.
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
- // 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 });
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 (method === "POST" && init?.body != null) {
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
- const response = await transport.handleRequest(request, { parsedBody });
159
- await transport.close();
160
- await mcpServer.close();
161
- return response;
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
  });
@@ -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,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"}
@@ -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.0",
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.15.1"
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 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
@@ -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 each
124
- * request. Each tenant gets its own cwd, env, and in-memory fs.
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 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.
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
- // 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 });
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 (method === "POST" && init?.body != null) {
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
- const response = await transport.handleRequest(request, { parsedBody });
206
- await transport.close();
207
- await mcpServer.close();
208
- return response;
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 (option.required) {
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