@goke/mcp 0.0.13 → 0.1.0

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
@@ -25,7 +25,7 @@ MCP server Your CLI
25
25
 
26
26
  1. **Discover** — calls `tools/list` on the MCP server to get every tool + its JSON Schema
27
27
  2. **Register** — creates a CLI command per tool with `--options` derived from the schema
28
- 3. **Cache** — tools and session ID are cached for 1 hour (no network on subsequent runs). Expired cache is still used when a live fetch is impossible (no token, 401 on `--help`)
28
+ 3. **Cache** — tool schemas are cached for 1 hour (no network on subsequent runs). Expired cache is still used when a live fetch is impossible (no token, 401 on `--help`)
29
29
  4. **Execute** — on invocation, connects to the server and calls the tool with coerced arguments
30
30
  5. **OAuth** — if the server returns 401, automatically opens the browser for OAuth, then retries. `--help`, `--version`, `completions`, and no-args never start OAuth
31
31
 
@@ -199,8 +199,8 @@ When you expose a cli as a **remote** MCP (over `StreamableHTTPServerTransport`,
199
199
  The recipe is:
200
200
 
201
201
  1. Define the cli **once**.
202
- 2. On every new MCP session, **clone** the cli with per-tenant `{ cwd, env, fs, stdin }` and mount it on a fresh session-scoped `Server` via `addCliToolsToMcp({ cli: tenantClone, server })`.
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.
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.
204
204
 
205
205
  ### Write commands against `ctx`
206
206
 
@@ -231,64 +231,36 @@ cli
231
231
 
232
232
  `ctx.fs` satisfies the `GokeFs` interface — a Node-compatible async filesystem API. You can point it at a real directory, a virtual in-memory store, an S3 bucket adapter, a `memfs`, or anything else you can wrap behind that interface.
233
233
 
234
- ### Clone the cli per session
234
+ ### Clone the cli per request
235
235
 
236
236
  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
237
 
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.
239
+
238
240
  You build one `handleMcpRequest(request: Request): Promise<Response>` function and mount it wherever you route HTTP:
239
241
 
240
242
  ```ts
241
- import { randomUUID } from "node:crypto"
242
243
  import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js"
243
244
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
244
- import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"
245
245
  import { addCliToolsToMcp } from "@goke/mcp"
246
246
  import type { GokeFs } from "goke"
247
247
 
248
- // Wherever you store per-user state — DB, Redis, config files, etc.
249
248
  declare function resolveTenant(tenantId: string): {
250
249
  cwd: string
251
250
  env: Record<string, string>
252
- fs: GokeFs // your filesystem adapter
251
+ fs: GokeFs
253
252
  }
254
253
 
255
- const transports = new Map<string, WebStandardStreamableHTTPServerTransport>()
256
-
257
254
  export async function handleMcpRequest(request: Request): Promise<Response> {
258
- // Pre-parse the body so we can use it for both routing decisions
259
- // (is this an `initialize` request?) and as the pre-parsed body
260
- // forwarded to the transport via `HandleRequestOptions.parsedBody`.
261
255
  let parsedBody: unknown
262
256
  if (request.method === "POST") {
263
257
  parsedBody = await request.clone().json().catch(() => undefined)
264
258
  }
265
259
 
266
- const sessionId = request.headers.get("mcp-session-id")
267
-
268
- // Existing session — route to its transport.
269
- if (sessionId && transports.has(sessionId)) {
270
- return transports.get(sessionId)!.handleRequest(request, { parsedBody })
271
- }
272
-
273
- // No session yet — must be an `initialize` request.
274
- if (!isInitializeRequest(parsedBody)) {
275
- return Response.json(
276
- {
277
- jsonrpc: "2.0",
278
- error: { code: -32000, message: "Bad Request: No valid session ID provided" },
279
- id: null,
280
- },
281
- { status: 400 },
282
- )
283
- }
284
-
285
- // Derive the tenant from whatever header/cookie/JWT you use.
286
260
  const tenantId = request.headers.get("x-tenant-id")
287
261
  if (!tenantId) return new Response("missing x-tenant-id", { status: 401 })
288
262
  const tenant = resolveTenant(tenantId)
289
263
 
290
- // Clone the base cli with tenant-specific cwd/env/fs. Every tool
291
- // call on this session now sees the tenant's state via `ctx`.
292
264
  const tenantCli = baseCli.clone({
293
265
  cwd: tenant.cwd,
294
266
  env: { ...tenant.env, TENANT_ID: tenantId },
@@ -302,22 +274,17 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
302
274
  addCliToolsToMcp({ cli: tenantCli, server: mcpServer })
303
275
 
304
276
  const transport = new WebStandardStreamableHTTPServerTransport({
305
- sessionIdGenerator: () => randomUUID(),
306
- enableJsonResponse: true, // pure request/response; no SSE to manage
307
- onsessioninitialized: (sid) => {
308
- transports.set(sid, transport)
309
- },
310
- onsessionclosed: (sid) => {
311
- transports.delete(sid)
312
- },
277
+ sessionIdGenerator: undefined,
278
+ enableJsonResponse: true,
313
279
  })
314
- transport.onclose = () => {
315
- const sid = transport.sessionId
316
- if (sid) transports.delete(sid)
317
- }
318
280
 
319
281
  await mcpServer.connect(transport)
320
- return transport.handleRequest(request, { parsedBody })
282
+ try {
283
+ return await transport.handleRequest(request, { parsedBody })
284
+ } finally {
285
+ await transport.close()
286
+ await mcpServer.close()
287
+ }
321
288
  }
322
289
  ```
323
290
 
@@ -353,7 +320,7 @@ export async function POST(request: Request) {
353
320
 
354
321
  **Key guarantees**
355
322
 
356
- - Every tool call inside a session runs against `tenantCli`'s `cwd` / `env` / `fs` not the base cli's and not another tenant's.
323
+ - Every tool call on a request runs against `tenantCli`'s `cwd` / `env` / `fs`, not the base cli's and not another tenant's.
357
324
  - `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.
358
325
  - `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.
359
326
  - Actions that `throw` are caught and returned as `{ isError: true, content: [message, stderr] }`.
@@ -486,7 +453,7 @@ Registers MCP tool commands on a goke CLI instance.
486
453
  |--------|------|---------|-------------|
487
454
  | `cli` | `Goke` | **required** | The goke CLI instance to add commands to |
488
455
  | `getMcpUrl` | `() => string \| undefined` | — | Returns the MCP server URL. Return the URL even when the user is not logged in so `--help` still works |
489
- | `getMcpTransport` | `(sessionId?) => Transport \| null` | — | Custom transport. Use for stdio or anything `getMcpUrl` cannot express |
456
+ | `getMcpTransport` | `() => Transport \| null` | — | Custom transport. Use for stdio or anything `getMcpUrl` cannot express |
490
457
  | `getHeaders` | `() => Record<string, string> \| undefined` | — | Extra HTTP headers (for example `Authorization`). Used with `getMcpUrl` |
491
458
  | `argv` | `string[]` | `process.argv.slice(2)` | Args used to skip live discovery on help and already registered commands |
492
459
  | `commandPrefix` | `string` | `''` | Prefix for commands (e.g. `'mcp'` makes `mcp notion-search`) |
@@ -554,7 +521,7 @@ Tokens are persisted via the `oauth.save()` callback you provide, so subsequent
554
521
 
555
522
  ## Caching
556
523
 
557
- Tools and the MCP session ID are cached for **1 hour** to avoid connecting on every invocation. The cache is managed through the `loadCache`/`saveCache` callbacks you control where it's stored (file, database, env, etc.).
524
+ Tool schemas are cached for **1 hour** so `--help` and command registration do not hit the network on every invocation. The cache is managed through the `loadCache`/`saveCache` callbacks. You control where it is stored (file, database, env, etc.). Each tool call still opens a new MCP connection. There is no `Mcp-Session-Id` reuse.
558
525
 
559
526
  When the cache expires or a tool call fails, the cache is cleared and tools are re-fetched on the next run.
560
527
 
@@ -1,7 +1,10 @@
1
1
  // First-run help and stale-cache behavior for addMcpCommands.
2
2
  import http from 'node:http';
3
3
  import { describe, expect, it } from 'vitest';
4
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
5
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
6
  import { goke } from 'goke';
7
+ import { addCliToolsToMcp } from '../cli-to-mcp.js';
5
8
  import { addMcpCommands } from '../index.js';
6
9
  const staleCache = (over = {}) => ({
7
10
  tools: [
@@ -12,7 +15,6 @@ const staleCache = (over = {}) => ({
12
15
  },
13
16
  ],
14
17
  timestamp: Date.now() - 2 * 60 * 60 * 1000,
15
- sessionId: 'stale-session',
16
18
  ...over,
17
19
  });
18
20
  function captureErrors() {
@@ -124,4 +126,64 @@ describe('addMcpCommands first-run help', () => {
124
126
  });
125
127
  expect(transportCalls).toBe(0);
126
128
  });
129
+ it('does not pass a session id into getMcpTransport', async () => {
130
+ const sessionIds = [];
131
+ const cli = goke('testcli');
132
+ await addMcpCommands({
133
+ cli,
134
+ argv: ['find_bookmarks'],
135
+ getMcpTransport: (sessionId) => {
136
+ sessionIds.push(sessionId);
137
+ return null;
138
+ },
139
+ loadCache: () => ({
140
+ tools: [
141
+ {
142
+ name: 'find_bookmarks',
143
+ description: 'Find bookmarks',
144
+ inputSchema: { type: 'object', properties: {} },
145
+ },
146
+ ],
147
+ timestamp: Date.now(),
148
+ sessionId: 'should-not-be-reused',
149
+ }),
150
+ saveCache: () => { },
151
+ });
152
+ const command = cli.commands.find((entry) => entry.name === 'find_bookmarks');
153
+ try {
154
+ await command?.commandAction?.({});
155
+ }
156
+ catch {
157
+ // transport is null, so the action exits
158
+ }
159
+ expect(sessionIds).toEqual([undefined]);
160
+ });
161
+ it('caches tools without a session id', async () => {
162
+ const saved = [];
163
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
164
+ const serverCli = goke('server-cli');
165
+ serverCli.command('ping', 'Ping').action(() => 'pong');
166
+ const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
167
+ addCliToolsToMcp({ cli: serverCli, server });
168
+ await server.connect(serverTransport);
169
+ const cli = goke('testcli');
170
+ try {
171
+ await addMcpCommands({
172
+ cli,
173
+ argv: ['ping'],
174
+ getMcpTransport: () => clientTransport,
175
+ loadCache: () => undefined,
176
+ saveCache: (cache) => {
177
+ if (cache)
178
+ saved.push(cache);
179
+ },
180
+ });
181
+ }
182
+ finally {
183
+ await server.close();
184
+ }
185
+ expect(saved).toHaveLength(1);
186
+ expect(saved[0]?.tools.map((tool) => tool.name)).toEqual(['ping']);
187
+ expect(saved[0]).not.toHaveProperty('sessionId');
188
+ });
127
189
  });
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Proves that one goke cli exposed over the MCP streamable-HTTP
5
5
  * transport can serve multiple concurrent users with fully isolated
6
- * state (in-memory fs + cwd + env) no shared host process stdio,
7
- * no cross-tenant leaks.
6
+ * state (in-memory fs + cwd + env). Isolation comes from the
7
+ * `x-tenant-id` header on each POST, not from `Mcp-Session-Id`.
8
8
  *
9
9
  * Wiring choices worth calling out:
10
10
  *
@@ -18,7 +18,8 @@
18
18
  * into pure request/response JSON. GET SSE opens are answered
19
19
  * with `405`, which the client treats as "server does not offer
20
20
  * SSE" and moves on (see `_startOrAuthSse` in the SDK client).
21
- * - Each session gets its own cli **clone** via
21
+ * - Each HTTP POST gets a fresh transport with
22
+ * `sessionIdGenerator: undefined` and a cli **clone** via
22
23
  * `baseCli.clone({ cwd, env, fs })`. The clone inherits the
23
24
  * command tree but owns its own `{ cwd, env, fs }`, which is
24
25
  * 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;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG"}
1
+ {"version":3,"file":"http-multi-tenant.test.d.ts","sourceRoot":"","sources":["../../src/__test__/http-multi-tenant.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG"}
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Proves that one goke cli exposed over the MCP streamable-HTTP
5
5
  * transport can serve multiple concurrent users with fully isolated
6
- * state (in-memory fs + cwd + env) no shared host process stdio,
7
- * no cross-tenant leaks.
6
+ * state (in-memory fs + cwd + env). Isolation comes from the
7
+ * `x-tenant-id` header on each POST, not from `Mcp-Session-Id`.
8
8
  *
9
9
  * Wiring choices worth calling out:
10
10
  *
@@ -18,19 +18,18 @@
18
18
  * into pure request/response JSON. GET SSE opens are answered
19
19
  * with `405`, which the client treats as "server does not offer
20
20
  * SSE" and moves on (see `_startOrAuthSse` in the SDK client).
21
- * - Each session gets its own cli **clone** via
21
+ * - Each HTTP POST gets a fresh transport with
22
+ * `sessionIdGenerator: undefined` and a cli **clone** via
22
23
  * `baseCli.clone({ cwd, env, fs })`. The clone inherits the
23
24
  * command tree but owns its own `{ cwd, env, fs }`, which is
24
25
  * what `runCliTool` forwards into every action through
25
26
  * `ctx.process.*` / `ctx.fs`.
26
27
  */
27
- import { randomUUID } from "node:crypto";
28
28
  import path from "node:path";
29
29
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
30
30
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
31
31
  import { Server as McpLowLevelServer } from "@modelcontextprotocol/sdk/server/index.js";
32
32
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
33
- import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
34
33
  import { goke } from "goke";
35
34
  import { describe, expect, it } from "vitest";
36
35
  import { z } from "zod";
@@ -80,7 +79,7 @@ class InMemoryFs {
80
79
  * One cli definition, reused across tenants. Commands read / write
81
80
  * through `ctx.fs` and resolve paths against `ctx.process.cwd`, so
82
81
  * the *same* code runs per tenant but talks to a tenant-specific
83
- * filesystem when invoked via the session-scoped clone below.
82
+ * filesystem when invoked via the per-request clone below.
84
83
  */
85
84
  function buildBaseCli() {
86
85
  const cli = goke("notes-app");
@@ -102,21 +101,20 @@ function buildBaseCli() {
102
101
  return cli;
103
102
  }
104
103
  /**
105
- * Build a `FetchLike` that routes MCP streamable-HTTP traffic into
106
- * in-process session-scoped `WebStandardStreamableHTTPServerTransport`
107
- * instances. One transport + one cli clone per session. Each session
108
- * is keyed by `mcp-session-id`; initialization requests pick a tenant
109
- * via the `x-tenant-id` header.
110
- *
111
- * Returns both the custom fetch and the transports map so tests can
112
- * inspect session state if needed.
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.
113
107
  */
114
108
  function createMultiTenantFetch(options) {
115
109
  const { baseCli, resolveTenant } = options;
116
- const transports = new Map();
110
+ const sessionHeaders = [];
117
111
  const customFetch = async (url, init) => {
118
112
  const method = (init?.method ?? "GET").toUpperCase();
119
113
  const headers = new Headers(init?.headers);
114
+ const sessionHeader = headers.get("mcp-session-id");
115
+ if (sessionHeader) {
116
+ sessionHeaders.push(sessionHeader);
117
+ }
120
118
  // Pure request/response mode: tell the client there's no SSE
121
119
  // available on GET. `_startOrAuthSse` in the SDK client treats
122
120
  // 405 as "server does not offer SSE" and moves on gracefully.
@@ -136,28 +134,10 @@ function createMultiTenantFetch(options) {
136
134
  parsedBody = JSON.parse(bodyText);
137
135
  }
138
136
  }
139
- // Rebuild a plain Request with the same method + headers. The
140
- // transport reads accept/content-type from here and uses
141
- // `parsedBody` for the actual JSON-RPC payload.
142
137
  const request = new Request(url.toString(), {
143
138
  method,
144
139
  headers,
145
140
  });
146
- const sessionId = headers.get("mcp-session-id");
147
- // Existing session: route to its transport.
148
- if (sessionId && transports.has(sessionId)) {
149
- return transports.get(sessionId).handleRequest(request, { parsedBody });
150
- }
151
- // New session: must be an initialize POST.
152
- if (method !== "POST" || !isInitializeRequest(parsedBody)) {
153
- return new Response(JSON.stringify({
154
- jsonrpc: "2.0",
155
- error: { code: -32000, message: "Bad Request: No valid session ID provided" },
156
- id: null,
157
- }), { status: 400, headers: { "content-type": "application/json" } });
158
- }
159
- // Resolve the tenant from the custom header, build a cli clone
160
- // with its cwd/env/fs, and spin up a session-scoped MCP server.
161
141
  const tenantId = headers.get("x-tenant-id");
162
142
  if (!tenantId) {
163
143
  return new Response("missing x-tenant-id header", { status: 401 });
@@ -171,26 +151,16 @@ function createMultiTenantFetch(options) {
171
151
  const mcpServer = new McpLowLevelServer({ name: "notes-app-mcp", version: "1.0.0" }, { capabilities: {} });
172
152
  addCliToolsToMcp({ cli: tenantCli, server: mcpServer });
173
153
  const transport = new WebStandardStreamableHTTPServerTransport({
174
- sessionIdGenerator: () => randomUUID(),
175
- // Pure request/response — no SSE streaming to clean up.
154
+ sessionIdGenerator: undefined,
176
155
  enableJsonResponse: true,
177
- onsessioninitialized: (sid) => {
178
- transports.set(sid, transport);
179
- },
180
- onsessionclosed: (sid) => {
181
- transports.delete(sid);
182
- },
183
156
  });
184
- transport.onclose = () => {
185
- const sid = transport.sessionId;
186
- if (sid) {
187
- transports.delete(sid);
188
- }
189
- };
190
157
  await mcpServer.connect(transport);
191
- return transport.handleRequest(request, { parsedBody });
158
+ const response = await transport.handleRequest(request, { parsedBody });
159
+ await transport.close();
160
+ await mcpServer.close();
161
+ return response;
192
162
  };
193
- return { fetch: customFetch, transports };
163
+ return { fetch: customFetch, sessionHeaders };
194
164
  }
195
165
  // ─── Tests ────────────────────────────────────────────────────────
196
166
  describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () => {
@@ -207,7 +177,7 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
207
177
  env: { ROLE: "reader" },
208
178
  fs: new InMemoryFs(),
209
179
  });
210
- const { fetch: tenantFetch } = createMultiTenantFetch({
180
+ const { fetch: tenantFetch, sessionHeaders } = createMultiTenantFetch({
211
181
  baseCli,
212
182
  resolveTenant: (id) => {
213
183
  const tenant = tenants.get(id);
@@ -232,14 +202,14 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
232
202
  await client.connect(transport);
233
203
  return client;
234
204
  }
235
- return { tenants, connectTenant };
205
+ return { tenants, connectTenant, sessionHeaders };
236
206
  }
237
207
  function firstTextBlock(result) {
238
208
  const content = result.content ?? [];
239
209
  return content.find((block) => block.type === "text")?.text ?? "";
240
210
  }
241
- it("routes each session to its own cli clone with tenant-specific cwd/env/fs", async () => {
242
- const { tenants, connectTenant } = setupScenario();
211
+ it("routes each request to its own cli clone with tenant-specific cwd/env/fs", async () => {
212
+ const { tenants, connectTenant, sessionHeaders } = setupScenario();
243
213
  const aliceClient = await connectTenant("tenant-a");
244
214
  const bobClient = await connectTenant("tenant-b");
245
215
  try {
@@ -250,8 +220,8 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
250
220
  expect(aliceTools).toEqual(["load", "save"]);
251
221
  expect(bobTools).toEqual(["load", "save"]);
252
222
  // Both tenants write a file called `notes.txt` with different
253
- // content. Since each session uses its own cli clone (with
254
- // its own cwd + fs), the writes land in separate Maps.
223
+ // content. Since each request clones the cli with that tenant's
224
+ // cwd + fs, the writes land in separate Maps.
255
225
  const aliceSave = await aliceClient.callTool({
256
226
  name: "save",
257
227
  arguments: { filename: "notes.txt", content: "alice-secret" },
@@ -285,6 +255,7 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
285
255
  expect([...tenantBFs.files.keys()]).toEqual(["/workspace-b/notes.txt"]);
286
256
  expect(tenantAFs.files.get("/workspace-a/notes.txt")).toBe("alice-secret");
287
257
  expect(tenantBFs.files.get("/workspace-b/notes.txt")).toBe("bob-secret");
258
+ expect(sessionHeaders).toEqual([]);
288
259
  }
289
260
  finally {
290
261
  await aliceClient.close();
package/dist/auth.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { OAuthFlowResult, StartOAuthFlowOptions } from "./types.js";
2
2
  /**
3
3
  * Start the OAuth flow for an MCP server.
4
- * This is an internal function - consumers should not call this directly.
5
- * It is automatically triggered by addMcpCommands when a 401 error occurs.
4
+ *
5
+ * Used internally by addMcpCommands on 401 errors, but also available
6
+ * for CLIs that need explicit control over the auth flow (e.g. a login
7
+ * command that runs the flow in a background daemon).
6
8
  *
7
9
  * This function:
8
10
  * 1. Starts a local callback server on a random port
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAiB,eAAe,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AA0BxF;;;;;;;;;;;;GAYG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC,CA4E7F;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAazD"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAiB,eAAe,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AA0BxF;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC,CA4E7F;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAazD"}
package/dist/auth.js CHANGED
@@ -24,8 +24,10 @@ async function openBrowser(url) {
24
24
  }
25
25
  /**
26
26
  * Start the OAuth flow for an MCP server.
27
- * This is an internal function - consumers should not call this directly.
28
- * It is automatically triggered by addMcpCommands when a 401 error occurs.
27
+ *
28
+ * Used internally by addMcpCommands on 401 errors, but also available
29
+ * for CLIs that need explicit control over the auth flow (e.g. a login
30
+ * command that runs the flow in a background daemon).
29
31
  *
30
32
  * This function:
31
33
  * 1. Starts a local callback server on a random port
package/dist/index.d.ts CHANGED
@@ -9,7 +9,6 @@
9
9
  *
10
10
  * - **Auto-discovery**: Fetches all tools from the MCP server and creates CLI commands
11
11
  * - **Caching**: Tools are cached for 1 hour to avoid reconnecting on every invocation
12
- * - **Session reuse**: MCP session IDs are cached to skip initialization handshake
13
12
  * - **Type-aware parsing**: Handles string, number, boolean, object, and array arguments
14
13
  * - **JSON schema support**: Generates CLI options from tool input schemas
15
14
  * - **OAuth support**: Automatic OAuth authentication on 401 errors (lazy auth)
@@ -41,11 +40,12 @@
41
40
  */
42
41
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
43
42
  import type { Goke } from "goke";
43
+ export { startOAuthFlow } from "./auth.js";
44
44
  import type { McpOAuthConfig } from "./types.js";
45
45
  export { addCliToolsToMcp, createMcpAction } from "./cli-to-mcp.js";
46
46
  export type { AddCliToolsToMcpOptions, CreateMcpActionOptions } from "./cli-to-mcp.js";
47
47
  export type { Transport };
48
- export type { McpOAuthConfig, McpOAuthState } from "./types.js";
48
+ export type { McpOAuthConfig, McpOAuthState, StartOAuthFlowOptions, OAuthFlowResult } from "./types.js";
49
49
  export interface CachedMcpTools {
50
50
  tools: Array<{
51
51
  name: string;
@@ -53,7 +53,6 @@ export interface CachedMcpTools {
53
53
  inputSchema?: unknown;
54
54
  }>;
55
55
  timestamp: number;
56
- sessionId?: string;
57
56
  }
58
57
  export interface AddMcpCommandsOptions {
59
58
  cli: Goke;
@@ -76,9 +75,8 @@ export interface AddMcpCommandsOptions {
76
75
  /**
77
76
  * Returns a transport to connect to the MCP server, or null if not configured.
78
77
  * Use this for stdio servers or any setup `getMcpUrl` cannot express.
79
- * @param sessionId - Optional session ID from a still-valid cache
80
78
  */
81
- getMcpTransport?: (sessionId?: string) => Transport | null | Promise<Transport | null>;
79
+ getMcpTransport?: () => Transport | null | Promise<Transport | null>;
82
80
  /**
83
81
  * Argv used to decide whether to skip live discovery.
84
82
  * Defaults to `process.argv.slice(2)`.
@@ -118,7 +116,6 @@ export interface AddMcpCommandsOptions {
118
116
  * Adds MCP tool commands to a goke CLI instance.
119
117
  *
120
118
  * Tools are cached for 1 hour to avoid connecting on every CLI invocation.
121
- * Session ID is also cached to skip MCP initialization handshake.
122
119
  *
123
120
  * OAuth is lazy - authentication only happens when a 401 error occurs.
124
121
  * After successful auth, the operation is automatically retried.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+CAA+C,CAAC;AAC/E,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAKjC,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,YAAY,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACpE,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAC;AAGvF,YAAY,EAAE,SAAS,EAAE,CAAC;AAC1B,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhE,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,KAAK,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,WAAW,CAAC,EAAE,OAAO,CAAC;KACvB,CAAC,CAAC;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAID,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,IAAI,CAAC;IACV;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAErC;;;;OAIG;IACH,eAAe,CAAC,EAAE,CAAC,SAAS,CAAC,EAAE,MAAM,KAAK,SAAS,GAAG,IAAI,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC;IAEvF;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAEhB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC;IAEtD;;;;;;;;;;;;;;OAcG;IACH,KAAK,CAAC,EAAE,cAAc,CAAC;IAEvB;;OAEG;IACH,SAAS,EAAE,MAAM,cAAc,GAAG,SAAS,CAAC;IAE5C;;OAEG;IACH,SAAS,EAAE,CAAC,KAAK,EAAE,cAAc,GAAG,SAAS,KAAK,IAAI,CAAC;CACxD;AA2JD;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CA4NlF"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+CAA+C,CAAC;AAC/E,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAKjC,OAAO,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,YAAY,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACpE,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAC;AAGvF,YAAY,EAAE,SAAS,EAAE,CAAC;AAC1B,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAExG,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,KAAK,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,WAAW,CAAC,EAAE,OAAO,CAAC;KACvB,CAAC,CAAC;IACH,SAAS,EAAE,MAAM,CAAC;CACnB;AAID,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,IAAI,CAAC;IACV;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAErC;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,SAAS,GAAG,IAAI,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC;IAErE;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAEhB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC;IAEtD;;;;;;;;;;;;;;OAcG;IACH,KAAK,CAAC,EAAE,cAAc,CAAC;IAEvB;;OAEG;IACH,SAAS,EAAE,MAAM,cAAc,GAAG,SAAS,CAAC;IAE5C;;OAEG;IACH,SAAS,EAAE,CAAC,KAAK,EAAE,cAAc,GAAG,SAAS,KAAK,IAAI,CAAC;CACxD;AAwJD;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAkNlF"}
package/dist/index.js CHANGED
@@ -9,7 +9,6 @@
9
9
  *
10
10
  * - **Auto-discovery**: Fetches all tools from the MCP server and creates CLI commands
11
11
  * - **Caching**: Tools are cached for 1 hour to avoid reconnecting on every invocation
12
- * - **Session reuse**: MCP session IDs are cached to skip initialization handshake
13
12
  * - **Type-aware parsing**: Handles string, number, boolean, object, and array arguments
14
13
  * - **JSON schema support**: Generates CLI options from tool input schemas
15
14
  * - **OAuth support**: Automatic OAuth authentication on 401 errors (lazy auth)
@@ -45,6 +44,7 @@ import { wrapJsonSchema } from "goke";
45
44
  import yaml from "js-yaml";
46
45
  import { FileOAuthProvider } from "./oauth-provider.js";
47
46
  import { startOAuthFlow, isAuthRequiredError } from "./auth.js";
47
+ export { startOAuthFlow } from "./auth.js";
48
48
  export { addCliToolsToMcp, createMcpAction } from "./cli-to-mcp.js";
49
49
  const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
50
50
  /**
@@ -140,7 +140,7 @@ function matchesRegisteredCommand({ argv, cli }) {
140
140
  return nameParts.every((part, i) => parts[i] === part);
141
141
  });
142
142
  }
143
- function createTransportWithAuth({ url, sessionId, oauthState, oauth, headers, }) {
143
+ function createTransportWithAuth({ url, oauthState, oauth, headers, }) {
144
144
  let authProvider;
145
145
  if (oauth && oauthState?.tokens) {
146
146
  authProvider = new FileOAuthProvider({
@@ -157,7 +157,6 @@ function createTransportWithAuth({ url, sessionId, oauthState, oauth, headers, }
157
157
  }
158
158
  const hasHeaders = headers && Object.keys(headers).length > 0;
159
159
  return new StreamableHTTPClientTransport(url, {
160
- sessionId,
161
160
  authProvider,
162
161
  requestInit: hasHeaders ? { headers } : undefined,
163
162
  });
@@ -166,16 +165,13 @@ function createTransportWithAuth({ url, sessionId, oauthState, oauth, headers, }
166
165
  * Adds MCP tool commands to a goke CLI instance.
167
166
  *
168
167
  * Tools are cached for 1 hour to avoid connecting on every CLI invocation.
169
- * Session ID is also cached to skip MCP initialization handshake.
170
168
  *
171
169
  * OAuth is lazy - authentication only happens when a 401 error occurs.
172
170
  * After successful auth, the operation is automatically retried.
173
171
  */
174
172
  export async function addMcpCommands(options) {
175
173
  const { cli, commandPrefix = "", clientName = "mcp-cli-client", getMcpUrl, getMcpTransport, getHeaders, oauth, loadCache, saveCache, argv = process.argv.slice(2), } = options;
176
- // Helper to get transport - supports both old and new API
177
- const getTransport = async (sessionId) => {
178
- // New API: getMcpUrl + oauth
174
+ const getTransport = async () => {
179
175
  if (getMcpUrl) {
180
176
  const mcpUrl = getMcpUrl();
181
177
  if (!mcpUrl) {
@@ -185,15 +181,13 @@ export async function addMcpCommands(options) {
185
181
  const oauthState = oauth?.load();
186
182
  return createTransportWithAuth({
187
183
  url,
188
- sessionId,
189
184
  oauthState,
190
185
  oauth,
191
186
  headers: getHeaders?.(),
192
187
  });
193
188
  }
194
- // Custom / stdio transport
195
189
  if (getMcpTransport) {
196
- return getMcpTransport(sessionId);
190
+ return getMcpTransport();
197
191
  }
198
192
  return null;
199
193
  };
@@ -226,10 +220,8 @@ export async function addMcpCommands(options) {
226
220
  const isCacheValid = cachedTools && (Date.now() - cachedTools.timestamp) < CACHE_TTL_MS;
227
221
  const skipLiveDiscovery = isHelpOrMetaArgv(argv) || matchesRegisteredCommand({ argv, cli });
228
222
  let tools;
229
- let cachedSessionId;
230
223
  if (isCacheValid && cachedTools) {
231
224
  tools = cachedTools.tools;
232
- cachedSessionId = cachedTools.sessionId;
233
225
  }
234
226
  else if (skipLiveDiscovery) {
235
227
  if (cachedTools) {
@@ -244,7 +236,6 @@ export async function addMcpCommands(options) {
244
236
  await client.connect(transport);
245
237
  const result = await client.listTools();
246
238
  tools = result.tools;
247
- const sessionId = transport.sessionId;
248
239
  saveCache({
249
240
  tools: tools.map((t) => ({
250
241
  name: t.name,
@@ -252,9 +243,7 @@ export async function addMcpCommands(options) {
252
243
  inputSchema: t.inputSchema,
253
244
  })),
254
245
  timestamp: Date.now(),
255
- sessionId,
256
246
  });
257
- cachedSessionId = sessionId;
258
247
  }
259
248
  catch (err) {
260
249
  const shouldAuth = isAuthRequiredError(err) && oauth && getMcpUrl && !skipLiveDiscovery;
@@ -321,7 +310,7 @@ export async function addMcpCommands(options) {
321
310
  // Goke already coerced all values via schemas — just extract the relevant keys
322
311
  const parsedArgs = extractToolArguments(cliOptions, inputSchema);
323
312
  const executeWithRetry = async (isRetry = false) => {
324
- const transport = await getTransport(isRetry ? undefined : cachedSessionId);
313
+ const transport = await getTransport();
325
314
  if (!transport) {
326
315
  console.error("MCP transport not available. Run login command first.");
327
316
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goke/mcp",
3
- "version": "0.0.13",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "description": "Dynamically generate CLI commands from MCP server tools",
6
6
  "repository": {
@@ -40,7 +40,7 @@
40
40
  "author": "Tommaso De Rossi, morse <beats.by.morse@gmail.com>",
41
41
  "license": "MIT",
42
42
  "dependencies": {
43
- "@modelcontextprotocol/sdk": "^1.0.0",
43
+ "@modelcontextprotocol/sdk": "^1.30.0",
44
44
  "js-yaml": "^4.1.1"
45
45
  },
46
46
  "peerDependencies": {
@@ -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.12.1"
54
+ "goke": "^6.15.1"
55
55
  },
56
56
  "scripts": {
57
57
  "clean": "rm -rf dist",
@@ -1,7 +1,10 @@
1
1
  // First-run help and stale-cache behavior for addMcpCommands.
2
2
  import http from 'node:http'
3
3
  import { describe, expect, it } from 'vitest'
4
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
5
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
4
6
  import { goke } from 'goke'
7
+ import { addCliToolsToMcp } from '../cli-to-mcp.js'
5
8
  import { addMcpCommands, type CachedMcpTools } from '../index.js'
6
9
 
7
10
  const staleCache = (over: Partial<CachedMcpTools> = {}): CachedMcpTools => ({
@@ -13,7 +16,6 @@ const staleCache = (over: Partial<CachedMcpTools> = {}): CachedMcpTools => ({
13
16
  },
14
17
  ],
15
18
  timestamp: Date.now() - 2 * 60 * 60 * 1000,
16
- sessionId: 'stale-session',
17
19
  ...over,
18
20
  })
19
21
 
@@ -136,4 +138,69 @@ describe('addMcpCommands first-run help', () => {
136
138
 
137
139
  expect(transportCalls).toBe(0)
138
140
  })
141
+
142
+ it('does not pass a session id into getMcpTransport', async () => {
143
+ const sessionIds: Array<string | undefined> = []
144
+ const cli = goke('testcli')
145
+
146
+ await addMcpCommands({
147
+ cli,
148
+ argv: ['find_bookmarks'],
149
+ getMcpTransport: (sessionId?: string) => {
150
+ sessionIds.push(sessionId)
151
+ return null
152
+ },
153
+ loadCache: () => ({
154
+ tools: [
155
+ {
156
+ name: 'find_bookmarks',
157
+ description: 'Find bookmarks',
158
+ inputSchema: { type: 'object', properties: {} },
159
+ },
160
+ ],
161
+ timestamp: Date.now(),
162
+ sessionId: 'should-not-be-reused',
163
+ }),
164
+ saveCache: () => {},
165
+ })
166
+
167
+ const command = cli.commands.find((entry) => entry.name === 'find_bookmarks')
168
+ try {
169
+ await command?.commandAction?.({})
170
+ } catch {
171
+ // transport is null, so the action exits
172
+ }
173
+
174
+ expect(sessionIds).toEqual([undefined])
175
+ })
176
+
177
+ it('caches tools without a session id', async () => {
178
+ const saved: CachedMcpTools[] = []
179
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
180
+ const serverCli = goke('server-cli')
181
+ serverCli.command('ping', 'Ping').action(() => 'pong')
182
+
183
+ const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: {} })
184
+ addCliToolsToMcp({ cli: serverCli, server })
185
+ await server.connect(serverTransport)
186
+
187
+ const cli = goke('testcli')
188
+ try {
189
+ await addMcpCommands({
190
+ cli,
191
+ argv: ['ping'],
192
+ getMcpTransport: () => clientTransport,
193
+ loadCache: () => undefined,
194
+ saveCache: (cache) => {
195
+ if (cache) saved.push(cache)
196
+ },
197
+ })
198
+ } finally {
199
+ await server.close()
200
+ }
201
+
202
+ expect(saved).toHaveLength(1)
203
+ expect(saved[0]?.tools.map((tool) => tool.name)).toEqual(['ping'])
204
+ expect(saved[0]).not.toHaveProperty('sessionId')
205
+ })
139
206
  })
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Proves that one goke cli exposed over the MCP streamable-HTTP
5
5
  * transport can serve multiple concurrent users with fully isolated
6
- * state (in-memory fs + cwd + env) no shared host process stdio,
7
- * no cross-tenant leaks.
6
+ * state (in-memory fs + cwd + env). Isolation comes from the
7
+ * `x-tenant-id` header on each POST, not from `Mcp-Session-Id`.
8
8
  *
9
9
  * Wiring choices worth calling out:
10
10
  *
@@ -18,15 +18,14 @@
18
18
  * into pure request/response JSON. GET SSE opens are answered
19
19
  * with `405`, which the client treats as "server does not offer
20
20
  * SSE" and moves on (see `_startOrAuthSse` in the SDK client).
21
- * - Each session gets its own cli **clone** via
21
+ * - Each HTTP POST gets a fresh transport with
22
+ * `sessionIdGenerator: undefined` and a cli **clone** via
22
23
  * `baseCli.clone({ cwd, env, fs })`. The clone inherits the
23
24
  * command tree but owns its own `{ cwd, env, fs }`, which is
24
25
  * what `runCliTool` forwards into every action through
25
26
  * `ctx.process.*` / `ctx.fs`.
26
27
  */
27
28
 
28
- import { randomUUID } from "node:crypto";
29
- import { Buffer } from "node:buffer";
30
29
  import path from "node:path";
31
30
 
32
31
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
@@ -34,7 +33,6 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
34
33
  import { Server as McpLowLevelServer } from "@modelcontextprotocol/sdk/server/index.js";
35
34
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
36
35
  import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js";
37
- import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
38
36
  import { goke, type Goke, type GokeFs } from "goke";
39
37
  import { describe, expect, it } from "vitest";
40
38
  import { z } from "zod";
@@ -94,7 +92,7 @@ class InMemoryFs implements GokeFs {
94
92
  * One cli definition, reused across tenants. Commands read / write
95
93
  * through `ctx.fs` and resolve paths against `ctx.process.cwd`, so
96
94
  * the *same* code runs per tenant but talks to a tenant-specific
97
- * filesystem when invoked via the session-scoped clone below.
95
+ * filesystem when invoked via the per-request clone below.
98
96
  */
99
97
  function buildBaseCli(): Goke {
100
98
  const cli = goke("notes-app");
@@ -122,9 +120,8 @@ function buildBaseCli(): Goke {
122
120
  // ─── In-process multi-tenant fetch ────────────────────────────────
123
121
 
124
122
  /**
125
- * Per-tenant state resolved from the `x-tenant-id` header on a
126
- * session-initialization request. Each tenant gets its own cwd,
127
- * env, and in-memory fs.
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
125
  */
129
126
  interface TenantState {
130
127
  cwd: string;
@@ -133,28 +130,27 @@ interface TenantState {
133
130
  }
134
131
 
135
132
  /**
136
- * Build a `FetchLike` that routes MCP streamable-HTTP traffic into
137
- * in-process session-scoped `WebStandardStreamableHTTPServerTransport`
138
- * instances. One transport + one cli clone per session. Each session
139
- * is keyed by `mcp-session-id`; initialization requests pick a tenant
140
- * via the `x-tenant-id` header.
141
- *
142
- * Returns both the custom fetch and the transports map so tests can
143
- * inspect session state if needed.
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.
144
136
  */
145
137
  function createMultiTenantFetch(options: {
146
138
  baseCli: Goke;
147
139
  resolveTenant: (tenantId: string) => TenantState;
148
140
  }): {
149
141
  fetch: FetchLike;
150
- transports: Map<string, WebStandardStreamableHTTPServerTransport>;
142
+ sessionHeaders: string[];
151
143
  } {
152
144
  const { baseCli, resolveTenant } = options;
153
- const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
145
+ const sessionHeaders: string[] = [];
154
146
 
155
147
  const customFetch: FetchLike = async (url, init) => {
156
148
  const method = (init?.method ?? "GET").toUpperCase();
157
149
  const headers = new Headers(init?.headers);
150
+ const sessionHeader = headers.get("mcp-session-id");
151
+ if (sessionHeader) {
152
+ sessionHeaders.push(sessionHeader);
153
+ }
158
154
 
159
155
  // Pure request/response mode: tell the client there's no SSE
160
156
  // available on GET. `_startOrAuthSse` in the SDK client treats
@@ -177,35 +173,11 @@ function createMultiTenantFetch(options: {
177
173
  }
178
174
  }
179
175
 
180
- // Rebuild a plain Request with the same method + headers. The
181
- // transport reads accept/content-type from here and uses
182
- // `parsedBody` for the actual JSON-RPC payload.
183
176
  const request = new Request(url.toString(), {
184
177
  method,
185
178
  headers,
186
179
  });
187
180
 
188
- const sessionId = headers.get("mcp-session-id");
189
-
190
- // Existing session: route to its transport.
191
- if (sessionId && transports.has(sessionId)) {
192
- return transports.get(sessionId)!.handleRequest(request, { parsedBody });
193
- }
194
-
195
- // New session: must be an initialize POST.
196
- if (method !== "POST" || !isInitializeRequest(parsedBody)) {
197
- return new Response(
198
- JSON.stringify({
199
- jsonrpc: "2.0",
200
- error: { code: -32000, message: "Bad Request: No valid session ID provided" },
201
- id: null,
202
- }),
203
- { status: 400, headers: { "content-type": "application/json" } },
204
- );
205
- }
206
-
207
- // Resolve the tenant from the custom header, build a cli clone
208
- // with its cwd/env/fs, and spin up a session-scoped MCP server.
209
181
  const tenantId = headers.get("x-tenant-id");
210
182
  if (!tenantId) {
211
183
  return new Response("missing x-tenant-id header", { status: 401 });
@@ -225,29 +197,18 @@ function createMultiTenantFetch(options: {
225
197
  addCliToolsToMcp({ cli: tenantCli, server: mcpServer });
226
198
 
227
199
  const transport = new WebStandardStreamableHTTPServerTransport({
228
- sessionIdGenerator: () => randomUUID(),
229
- // Pure request/response — no SSE streaming to clean up.
200
+ sessionIdGenerator: undefined,
230
201
  enableJsonResponse: true,
231
- onsessioninitialized: (sid) => {
232
- transports.set(sid, transport);
233
- },
234
- onsessionclosed: (sid) => {
235
- transports.delete(sid);
236
- },
237
202
  });
238
203
 
239
- transport.onclose = () => {
240
- const sid = transport.sessionId;
241
- if (sid) {
242
- transports.delete(sid);
243
- }
244
- };
245
-
246
204
  await mcpServer.connect(transport);
247
- return transport.handleRequest(request, { parsedBody });
205
+ const response = await transport.handleRequest(request, { parsedBody });
206
+ await transport.close();
207
+ await mcpServer.close();
208
+ return response;
248
209
  };
249
210
 
250
- return { fetch: customFetch, transports };
211
+ return { fetch: customFetch, sessionHeaders };
251
212
  }
252
213
 
253
214
  // ─── Tests ────────────────────────────────────────────────────────
@@ -267,7 +228,7 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
267
228
  fs: new InMemoryFs(),
268
229
  });
269
230
 
270
- const { fetch: tenantFetch } = createMultiTenantFetch({
231
+ const { fetch: tenantFetch, sessionHeaders } = createMultiTenantFetch({
271
232
  baseCli,
272
233
  resolveTenant: (id) => {
273
234
  const tenant = tenants.get(id);
@@ -297,7 +258,7 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
297
258
  return client;
298
259
  }
299
260
 
300
- return { tenants, connectTenant };
261
+ return { tenants, connectTenant, sessionHeaders };
301
262
  }
302
263
 
303
264
  function firstTextBlock(result: Awaited<ReturnType<Client["callTool"]>>): string {
@@ -305,8 +266,8 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
305
266
  return content.find((block) => block.type === "text")?.text ?? "";
306
267
  }
307
268
 
308
- it("routes each session to its own cli clone with tenant-specific cwd/env/fs", async () => {
309
- const { tenants, connectTenant } = setupScenario();
269
+ it("routes each request to its own cli clone with tenant-specific cwd/env/fs", async () => {
270
+ const { tenants, connectTenant, sessionHeaders } = setupScenario();
310
271
 
311
272
  const aliceClient = await connectTenant("tenant-a");
312
273
  const bobClient = await connectTenant("tenant-b");
@@ -320,8 +281,8 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
320
281
  expect(bobTools).toEqual(["load", "save"]);
321
282
 
322
283
  // Both tenants write a file called `notes.txt` with different
323
- // content. Since each session uses its own cli clone (with
324
- // its own cwd + fs), the writes land in separate Maps.
284
+ // content. Since each request clones the cli with that tenant's
285
+ // cwd + fs, the writes land in separate Maps.
325
286
  const aliceSave = await aliceClient.callTool({
326
287
  name: "save",
327
288
  arguments: { filename: "notes.txt", content: "alice-secret" },
@@ -359,6 +320,7 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
359
320
  expect([...tenantBFs.files.keys()]).toEqual(["/workspace-b/notes.txt"]);
360
321
  expect(tenantAFs.files.get("/workspace-a/notes.txt")).toBe("alice-secret");
361
322
  expect(tenantBFs.files.get("/workspace-b/notes.txt")).toBe("bob-secret");
323
+ expect(sessionHeaders).toEqual([]);
362
324
  } finally {
363
325
  await aliceClient.close();
364
326
  await bobClient.close();
package/src/auth.ts CHANGED
@@ -29,8 +29,10 @@ async function openBrowser(url: string): Promise<void> {
29
29
 
30
30
  /**
31
31
  * Start the OAuth flow for an MCP server.
32
- * This is an internal function - consumers should not call this directly.
33
- * It is automatically triggered by addMcpCommands when a 401 error occurs.
32
+ *
33
+ * Used internally by addMcpCommands on 401 errors, but also available
34
+ * for CLIs that need explicit control over the auth flow (e.g. a login
35
+ * command that runs the flow in a background daemon).
34
36
  *
35
37
  * This function:
36
38
  * 1. Starts a local callback server on a random port
package/src/index.ts CHANGED
@@ -9,7 +9,6 @@
9
9
  *
10
10
  * - **Auto-discovery**: Fetches all tools from the MCP server and creates CLI commands
11
11
  * - **Caching**: Tools are cached for 1 hour to avoid reconnecting on every invocation
12
- * - **Session reuse**: MCP session IDs are cached to skip initialization handshake
13
12
  * - **Type-aware parsing**: Handles string, number, boolean, object, and array arguments
14
13
  * - **JSON schema support**: Generates CLI options from tool input schemas
15
14
  * - **OAuth support**: Automatic OAuth authentication on 401 errors (lazy auth)
@@ -48,13 +47,14 @@ import { wrapJsonSchema } from "goke";
48
47
  import yaml from "js-yaml";
49
48
  import { FileOAuthProvider } from "./oauth-provider.js";
50
49
  import { startOAuthFlow, isAuthRequiredError } from "./auth.js";
50
+ export { startOAuthFlow } from "./auth.js";
51
51
  import type { McpOAuthConfig, McpOAuthState } from "./types.js";
52
52
  export { addCliToolsToMcp, createMcpAction } from "./cli-to-mcp.js";
53
53
  export type { AddCliToolsToMcpOptions, CreateMcpActionOptions } from "./cli-to-mcp.js";
54
54
 
55
- // Public exports - only types that consumers need
55
+ // Public exports
56
56
  export type { Transport };
57
- export type { McpOAuthConfig, McpOAuthState } from "./types.js";
57
+ export type { McpOAuthConfig, McpOAuthState, StartOAuthFlowOptions, OAuthFlowResult } from "./types.js";
58
58
 
59
59
  export interface CachedMcpTools {
60
60
  tools: Array<{
@@ -63,7 +63,6 @@ export interface CachedMcpTools {
63
63
  inputSchema?: unknown;
64
64
  }>;
65
65
  timestamp: number;
66
- sessionId?: string;
67
66
  }
68
67
 
69
68
  const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
@@ -91,9 +90,8 @@ export interface AddMcpCommandsOptions {
91
90
  /**
92
91
  * Returns a transport to connect to the MCP server, or null if not configured.
93
92
  * Use this for stdio servers or any setup `getMcpUrl` cannot express.
94
- * @param sessionId - Optional session ID from a still-valid cache
95
93
  */
96
- getMcpTransport?: (sessionId?: string) => Transport | null | Promise<Transport | null>;
94
+ getMcpTransport?: () => Transport | null | Promise<Transport | null>;
97
95
 
98
96
  /**
99
97
  * Argv used to decide whether to skip live discovery.
@@ -251,13 +249,11 @@ function matchesRegisteredCommand({ argv, cli }: { argv: string[]; cli: Goke })
251
249
 
252
250
  function createTransportWithAuth({
253
251
  url,
254
- sessionId,
255
252
  oauthState,
256
253
  oauth,
257
254
  headers,
258
255
  }: {
259
256
  url: URL
260
- sessionId?: string
261
257
  oauthState?: McpOAuthState
262
258
  oauth?: McpOAuthConfig
263
259
  headers?: Record<string, string>
@@ -280,7 +276,6 @@ function createTransportWithAuth({
280
276
 
281
277
  const hasHeaders = headers && Object.keys(headers).length > 0;
282
278
  return new StreamableHTTPClientTransport(url, {
283
- sessionId,
284
279
  authProvider,
285
280
  requestInit: hasHeaders ? { headers } : undefined,
286
281
  });
@@ -292,7 +287,6 @@ function createTransportWithAuth({
292
287
  * Adds MCP tool commands to a goke CLI instance.
293
288
  *
294
289
  * Tools are cached for 1 hour to avoid connecting on every CLI invocation.
295
- * Session ID is also cached to skip MCP initialization handshake.
296
290
  *
297
291
  * OAuth is lazy - authentication only happens when a 401 error occurs.
298
292
  * After successful auth, the operation is automatically retried.
@@ -311,9 +305,7 @@ export async function addMcpCommands(options: AddMcpCommandsOptions): Promise<vo
311
305
  argv = process.argv.slice(2),
312
306
  } = options;
313
307
 
314
- // Helper to get transport - supports both old and new API
315
- const getTransport = async (sessionId?: string): Promise<Transport | null> => {
316
- // New API: getMcpUrl + oauth
308
+ const getTransport = async (): Promise<Transport | null> => {
317
309
  if (getMcpUrl) {
318
310
  const mcpUrl = getMcpUrl();
319
311
  if (!mcpUrl) {
@@ -325,16 +317,14 @@ export async function addMcpCommands(options: AddMcpCommandsOptions): Promise<vo
325
317
 
326
318
  return createTransportWithAuth({
327
319
  url,
328
- sessionId,
329
320
  oauthState,
330
321
  oauth,
331
322
  headers: getHeaders?.(),
332
323
  });
333
324
  }
334
325
 
335
- // Custom / stdio transport
336
326
  if (getMcpTransport) {
337
- return getMcpTransport(sessionId);
327
+ return getMcpTransport();
338
328
  }
339
329
 
340
330
  return null;
@@ -376,11 +366,9 @@ export async function addMcpCommands(options: AddMcpCommandsOptions): Promise<vo
376
366
  isHelpOrMetaArgv(argv) || matchesRegisteredCommand({ argv, cli });
377
367
 
378
368
  let tools: CachedMcpTools["tools"] | undefined;
379
- let cachedSessionId: string | undefined;
380
369
 
381
370
  if (isCacheValid && cachedTools) {
382
371
  tools = cachedTools.tools;
383
- cachedSessionId = cachedTools.sessionId;
384
372
  } else if (skipLiveDiscovery) {
385
373
  if (cachedTools) {
386
374
  tools = cachedTools.tools;
@@ -394,8 +382,6 @@ export async function addMcpCommands(options: AddMcpCommandsOptions): Promise<vo
394
382
  const result = await client.listTools();
395
383
  tools = result.tools;
396
384
 
397
- const sessionId = (transport as { sessionId?: string }).sessionId;
398
-
399
385
  saveCache({
400
386
  tools: tools.map((t) => ({
401
387
  name: t.name,
@@ -403,9 +389,7 @@ export async function addMcpCommands(options: AddMcpCommandsOptions): Promise<vo
403
389
  inputSchema: t.inputSchema,
404
390
  })),
405
391
  timestamp: Date.now(),
406
- sessionId,
407
392
  });
408
- cachedSessionId = sessionId;
409
393
  } catch (err) {
410
394
  const shouldAuth = isAuthRequiredError(err) && oauth && getMcpUrl && !skipLiveDiscovery;
411
395
  if (shouldAuth) {
@@ -480,7 +464,7 @@ export async function addMcpCommands(options: AddMcpCommandsOptions): Promise<vo
480
464
  const parsedArgs = extractToolArguments(cliOptions, inputSchema);
481
465
 
482
466
  const executeWithRetry = async (isRetry = false): Promise<void> => {
483
- const transport = await getTransport(isRetry ? undefined : cachedSessionId);
467
+ const transport = await getTransport();
484
468
  if (!transport) {
485
469
  console.error("MCP transport not available. Run login command first.");
486
470
  process.exit(1);