@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 +21 -52
- package/dist/__test__/add-mcp-commands-help.test.js +134 -35
- package/dist/__test__/http-multi-tenant.test.d.ts +4 -3
- package/dist/__test__/http-multi-tenant.test.d.ts.map +1 -1
- package/dist/__test__/http-multi-tenant.test.js +26 -55
- package/dist/auth.d.ts +4 -2
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +4 -2
- package/dist/index.d.ts +9 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -21
- package/package.json +3 -3
- package/src/__test__/add-mcp-commands-help.test.ts +139 -35
- package/src/__test__/http-multi-tenant.test.ts +29 -67
- package/src/auth.ts +4 -2
- package/src/index.ts +49 -35
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** —
|
|
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
|
-
|
|
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
|
|
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
|
|
203
|
-
3. Inside command actions, always use the **injected** `ctx`
|
|
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
|
|
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
|
|
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:
|
|
306
|
-
enableJsonResponse: true,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
-
|
|
88
|
+
try {
|
|
70
89
|
await addMcpCommands({
|
|
71
90
|
cli,
|
|
72
|
-
|
|
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
|
-
|
|
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)
|
|
7
|
-
*
|
|
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
|
|
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
|
|
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)
|
|
7
|
-
*
|
|
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
|
|
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
|
|
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
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
254
|
-
//
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
package/dist/auth.d.ts.map
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
28
|
-
*
|
|
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
|