@goke/mcp 0.0.12 → 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
 
@@ -55,9 +55,9 @@ cli.completions()
55
55
  cli.parse()
56
56
  ```
57
57
 
58
- Always pass `getMcpUrl`. Do not return `undefined` just because the user has no token. `--help` and no-args must still run so the CLI can show `config` / login instructions.
58
+ If the MCP server is HTTP, pass `getMcpUrl` even when the user has no token. `--help` and no-args must still run so the CLI can show `config` / login instructions. Use `getMcpTransport` when you need stdio or a custom transport.
59
59
 
60
- For a Bearer token, pass `getHeaders` instead of a custom transport:
60
+ For an HTTP Bearer token, pass `getHeaders`:
61
61
 
62
62
  ```ts
63
63
  await addMcpCommands({
@@ -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,9 @@ 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 |
456
+ | `getMcpTransport` | `() => Transport \| null` | — | Custom transport. Use for stdio or anything `getMcpUrl` cannot express |
489
457
  | `getHeaders` | `() => Record<string, string> \| undefined` | — | Extra HTTP headers (for example `Authorization`). Used with `getMcpUrl` |
458
+ | `argv` | `string[]` | `process.argv.slice(2)` | Args used to skip live discovery on help and already registered commands |
490
459
  | `commandPrefix` | `string` | `''` | Prefix for commands (e.g. `'mcp'` makes `mcp notion-search`) |
491
460
  | `clientName` | `string` | `'mcp-cli-client'` | Name sent to the MCP server during connection |
492
461
  | `oauth` | `McpOAuthConfig` | — | OAuth config for servers that require authentication |
@@ -552,7 +521,7 @@ Tokens are persisted via the `oauth.save()` callback you provide, so subsequent
552
521
 
553
522
  ## Caching
554
523
 
555
- 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.
556
525
 
557
526
  When the cache expires or a tool call fails, the cache is cleared and tools are re-fetched on the next run.
558
527
 
@@ -1,6 +1,10 @@
1
1
  // First-run help and stale-cache behavior for addMcpCommands.
2
+ import http from 'node:http';
2
3
  import { describe, expect, it } from 'vitest';
4
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
5
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
6
  import { goke } from 'goke';
7
+ import { addCliToolsToMcp } from '../cli-to-mcp.js';
4
8
  import { addMcpCommands } from '../index.js';
5
9
  const staleCache = (over = {}) => ({
6
10
  tools: [
@@ -13,63 +17,79 @@ const staleCache = (over = {}) => ({
13
17
  timestamp: Date.now() - 2 * 60 * 60 * 1000,
14
18
  ...over,
15
19
  });
16
- async function withArgv(argv, fn) {
17
- const previous = process.argv;
18
- process.argv = ['node', 'testcli', ...argv];
19
- try {
20
- return await fn();
21
- }
22
- finally {
23
- process.argv = previous;
24
- }
20
+ function captureErrors() {
21
+ const errors = [];
22
+ const error = console.error;
23
+ console.error = (...args) => {
24
+ errors.push(args.map(String).join(' '));
25
+ };
26
+ return {
27
+ errors,
28
+ restore() {
29
+ console.error = error;
30
+ },
31
+ };
32
+ }
33
+ function listen401() {
34
+ const server = http.createServer((_req, res) => {
35
+ res.writeHead(401, { 'content-type': 'application/json' });
36
+ res.end(JSON.stringify({ error: 'unauthorized' }));
37
+ });
38
+ return new Promise((resolve) => {
39
+ server.listen(0, '127.0.0.1', () => {
40
+ const addr = server.address();
41
+ if (!addr || typeof addr === 'string')
42
+ throw new Error('no port');
43
+ resolve({
44
+ url: `http://127.0.0.1:${addr.port}/mcp`,
45
+ close: () => new Promise((done) => server.close(() => done())),
46
+ });
47
+ });
48
+ });
25
49
  }
26
50
  describe('addMcpCommands first-run help', () => {
27
51
  it('lets --help run when there is no token and no cache', async () => {
28
- const errors = [];
29
- const error = console.error;
30
- console.error = (...args) => {
31
- errors.push(args.map(String).join(' '));
32
- };
52
+ const io = captureErrors();
33
53
  const cli = goke('testcli');
34
54
  cli.command('config', 'Save token').action(() => { });
35
- await withArgv(['--help'], async () => {
55
+ try {
36
56
  await addMcpCommands({
37
57
  cli,
58
+ argv: ['--help'],
38
59
  getMcpTransport: () => null,
39
60
  loadCache: () => undefined,
40
61
  saveCache: () => { },
41
62
  });
42
- });
43
- console.error = error;
63
+ }
64
+ finally {
65
+ io.restore();
66
+ }
44
67
  const help = cli.helpText();
45
- expect(errors.join('\n')).not.toMatch(/Failed to connect/);
68
+ expect(io.errors.join('\n')).not.toMatch(/Failed to connect/);
46
69
  expect(help).toMatch(/config/);
47
70
  expect(help).not.toMatch(/find_bookmarks/);
48
71
  });
49
72
  it('registers stale cached tools when live fetch is impossible', async () => {
50
73
  const cli = goke('testcli');
51
- await withArgv(['--help'], async () => {
52
- await addMcpCommands({
53
- cli,
54
- getMcpTransport: () => null,
55
- loadCache: () => staleCache(),
56
- saveCache: () => { },
57
- });
74
+ await addMcpCommands({
75
+ cli,
76
+ argv: ['--help'],
77
+ getMcpTransport: () => null,
78
+ loadCache: () => staleCache(),
79
+ saveCache: () => { },
58
80
  });
59
81
  expect(cli.helpText()).toMatch(/find_bookmarks/);
60
82
  });
61
83
  it('does not start OAuth when --help gets a 401', async () => {
84
+ const server = await listen401();
62
85
  const authUrls = [];
63
- const errors = [];
64
- const error = console.error;
65
- console.error = (...args) => {
66
- errors.push(args.map(String).join(' '));
67
- };
86
+ const io = captureErrors();
68
87
  const cli = goke('testcli');
69
- await withArgv(['--help'], async () => {
88
+ try {
70
89
  await addMcpCommands({
71
90
  cli,
72
- getMcpUrl: () => 'http://127.0.0.1:1/mcp',
91
+ argv: ['--help'],
92
+ getMcpUrl: () => server.url,
73
93
  oauth: {
74
94
  clientName: 'test',
75
95
  load: () => undefined,
@@ -81,10 +101,89 @@ describe('addMcpCommands first-run help', () => {
81
101
  loadCache: () => undefined,
82
102
  saveCache: () => { },
83
103
  });
84
- });
85
- console.error = error;
104
+ }
105
+ finally {
106
+ io.restore();
107
+ await server.close();
108
+ }
86
109
  expect(authUrls).toEqual([]);
87
- expect(errors.join('\n')).not.toMatch(/Authentication required/);
110
+ expect(io.errors.join('\n')).not.toMatch(/Authentication required/);
88
111
  expect(cli.helpText()).toMatch(/Usage/);
89
112
  });
113
+ it('does not connect for an already registered command', async () => {
114
+ let transportCalls = 0;
115
+ const cli = goke('testcli');
116
+ cli.command('config', 'Save token').action(() => { });
117
+ await addMcpCommands({
118
+ cli,
119
+ argv: ['config', '--token', 'x'],
120
+ getMcpTransport: () => {
121
+ transportCalls += 1;
122
+ return null;
123
+ },
124
+ loadCache: () => undefined,
125
+ saveCache: () => { },
126
+ });
127
+ expect(transportCalls).toBe(0);
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
+ });
90
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