@goke/mcp 0.0.13 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -52
- package/dist/__test__/add-cli-tools-to-mcp.test.js +4 -18
- package/dist/__test__/add-mcp-commands-help.test.js +63 -1
- package/dist/__test__/create-mcp-action.test.js +114 -0
- package/dist/__test__/http-multi-tenant.test.d.ts +14 -9
- package/dist/__test__/http-multi-tenant.test.d.ts.map +1 -1
- package/dist/__test__/http-multi-tenant.test.js +63 -78
- package/dist/auth.d.ts +4 -2
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +4 -2
- package/dist/cli-to-mcp.d.ts.map +1 -1
- package/dist/cli-to-mcp.js +7 -2
- package/dist/index.d.ts +3 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -16
- package/package.json +3 -3
- package/src/__test__/add-cli-tools-to-mcp.test.ts +4 -18
- package/src/__test__/add-mcp-commands-help.test.ts +68 -1
- package/src/__test__/create-mcp-action.test.ts +147 -0
- package/src/__test__/http-multi-tenant.test.ts +69 -90
- package/src/auth.ts +4 -2
- package/src/cli-to-mcp.ts +9 -1
- package/src/index.ts +7 -23
|
@@ -292,6 +292,153 @@ describe("createMcpAction", () => {
|
|
|
292
292
|
}
|
|
293
293
|
});
|
|
294
294
|
|
|
295
|
+
it("keeps --flag <value> in properties but not in required", async () => {
|
|
296
|
+
const cli = goke("strada");
|
|
297
|
+
|
|
298
|
+
cli
|
|
299
|
+
.command("projects create <slug>", "Create a project")
|
|
300
|
+
.option("--traces-days <days>", z.string().optional().describe("Traces retention days"))
|
|
301
|
+
.option("--logs-days <days>", z.string().optional().describe("Logs retention days"))
|
|
302
|
+
.option("--errors-days <days>", z.string().optional().describe("Errors retention days"))
|
|
303
|
+
.option("--metrics-days <days>", z.string().optional().describe("Metrics retention days"))
|
|
304
|
+
.option("--all-days <days>", z.string().optional().describe("Retention for all signals"))
|
|
305
|
+
.action((slug, options) => ({ slug, ...options }));
|
|
306
|
+
|
|
307
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
308
|
+
|
|
309
|
+
cli.command("mcp", "Start MCP server").action(
|
|
310
|
+
createMcpAction({
|
|
311
|
+
cli,
|
|
312
|
+
createTransport: () => serverTransport,
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
cli.matchedCommandName = "mcp";
|
|
317
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp")!;
|
|
318
|
+
await mcpCommand.commandAction!({});
|
|
319
|
+
|
|
320
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
321
|
+
await client.connect(clientTransport);
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
const tools = await client.listTools();
|
|
325
|
+
const createTool = tools.tools.find((t) => t.name === "projects_create")!;
|
|
326
|
+
expect(createTool.inputSchema.properties).toHaveProperty("slug");
|
|
327
|
+
expect(createTool.inputSchema.properties).toHaveProperty("tracesDays");
|
|
328
|
+
expect(createTool.inputSchema.properties).toHaveProperty("logsDays");
|
|
329
|
+
expect(createTool.inputSchema.properties).toHaveProperty("allDays");
|
|
330
|
+
expect(createTool.inputSchema.required).toEqual(["slug"]);
|
|
331
|
+
|
|
332
|
+
const result = await client.callTool({
|
|
333
|
+
name: "projects_create",
|
|
334
|
+
arguments: { slug: "my-app" },
|
|
335
|
+
});
|
|
336
|
+
expect(firstTextContent(result)).toBe('{\n "slug": "my-app"\n}');
|
|
337
|
+
|
|
338
|
+
await expect(
|
|
339
|
+
client.callTool({
|
|
340
|
+
name: "projects_create",
|
|
341
|
+
arguments: {},
|
|
342
|
+
}),
|
|
343
|
+
).rejects.toThrow("Missing required argument: slug");
|
|
344
|
+
} finally {
|
|
345
|
+
await client.close();
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("puts schema-required --flag <value> in inputSchema.required", async () => {
|
|
350
|
+
const cli = goke("checks");
|
|
351
|
+
|
|
352
|
+
cli
|
|
353
|
+
.command("checks create", "Create a check")
|
|
354
|
+
.option("--url <url>", z.string().describe("URL to check"))
|
|
355
|
+
.option("--name <name>", z.string().describe("Check name"))
|
|
356
|
+
.option("--timeout [ms]", z.number().optional().describe("Timeout"))
|
|
357
|
+
.action((options) => options);
|
|
358
|
+
|
|
359
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
360
|
+
|
|
361
|
+
cli.command("mcp", "Start MCP server").action(
|
|
362
|
+
createMcpAction({
|
|
363
|
+
cli,
|
|
364
|
+
createTransport: () => serverTransport,
|
|
365
|
+
}),
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
cli.matchedCommandName = "mcp";
|
|
369
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp")!;
|
|
370
|
+
await mcpCommand.commandAction!({});
|
|
371
|
+
|
|
372
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
373
|
+
await client.connect(clientTransport);
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
const tools = await client.listTools();
|
|
377
|
+
const createTool = tools.tools.find((t) => t.name === "checks_create")!;
|
|
378
|
+
expect(createTool.inputSchema.properties).toHaveProperty("url");
|
|
379
|
+
expect(createTool.inputSchema.properties).toHaveProperty("name");
|
|
380
|
+
expect(createTool.inputSchema.properties).toHaveProperty("timeout");
|
|
381
|
+
expect(createTool.inputSchema.required).toEqual(["url", "name"]);
|
|
382
|
+
|
|
383
|
+
const result = await client.callTool({
|
|
384
|
+
name: "checks_create",
|
|
385
|
+
arguments: { url: "https://example.com", name: "home" },
|
|
386
|
+
});
|
|
387
|
+
expect(firstTextContent(result)).toContain("https://example.com");
|
|
388
|
+
|
|
389
|
+
await expect(
|
|
390
|
+
client.callTool({
|
|
391
|
+
name: "checks_create",
|
|
392
|
+
arguments: { name: "home" },
|
|
393
|
+
}),
|
|
394
|
+
).rejects.toThrow("Missing required argument: url");
|
|
395
|
+
} finally {
|
|
396
|
+
await client.close();
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("does not put wrapJsonSchema flags in required", async () => {
|
|
401
|
+
const cli = goke("wrapped");
|
|
402
|
+
|
|
403
|
+
cli
|
|
404
|
+
.command("config set", "Set a config value")
|
|
405
|
+
.option("--key <key>", wrapJsonSchema({ type: "string", description: "Config key" }))
|
|
406
|
+
.option("--value <value>", wrapJsonSchema({ type: "string", description: "Config value" }))
|
|
407
|
+
.action((options) => options);
|
|
408
|
+
|
|
409
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
410
|
+
|
|
411
|
+
cli.command("mcp", "Start MCP server").action(
|
|
412
|
+
createMcpAction({
|
|
413
|
+
cli,
|
|
414
|
+
createTransport: () => serverTransport,
|
|
415
|
+
}),
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
cli.matchedCommandName = "mcp";
|
|
419
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp")!;
|
|
420
|
+
await mcpCommand.commandAction!({});
|
|
421
|
+
|
|
422
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
423
|
+
await client.connect(clientTransport);
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
const tools = await client.listTools();
|
|
427
|
+
const setTool = tools.tools.find((t) => t.name === "config_set")!;
|
|
428
|
+
expect(setTool.inputSchema.properties).toHaveProperty("key");
|
|
429
|
+
expect(setTool.inputSchema.properties).toHaveProperty("value");
|
|
430
|
+
expect(setTool.inputSchema.required).toBeUndefined();
|
|
431
|
+
|
|
432
|
+
const result = await client.callTool({
|
|
433
|
+
name: "config_set",
|
|
434
|
+
arguments: {},
|
|
435
|
+
});
|
|
436
|
+
expect(firstTextContent(result)).toBe("{}");
|
|
437
|
+
} finally {
|
|
438
|
+
await client.close();
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
295
442
|
it("returns empty tool list when only the mcp command exists", async () => {
|
|
296
443
|
const cli = goke("empty-app");
|
|
297
444
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Multi-tenant remote-MCP test.
|
|
3
3
|
*
|
|
4
|
-
* Proves that one goke cli exposed over
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Proves that one goke cli exposed over stateless MCP streamable HTTP
|
|
5
|
+
* can serve multiple concurrent users with fully isolated state
|
|
6
|
+
* (in-memory fs + cwd + env) — no shared host process stdio, no
|
|
7
|
+
* cross-tenant leaks, no mcp-session-id map. Isolation comes from
|
|
8
|
+
* the `x-tenant-id` header on each POST, not from `Mcp-Session-Id`.
|
|
8
9
|
*
|
|
9
10
|
* Wiring choices worth calling out:
|
|
10
11
|
*
|
|
@@ -14,19 +15,21 @@
|
|
|
14
15
|
* transport's `fetch` hook without ever binding a TCP socket
|
|
15
16
|
* or spinning up `node:http` / Express. Same wire protocol,
|
|
16
17
|
* zero sockets.
|
|
17
|
-
* - `
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* - `sessionIdGenerator: undefined` is stateless mode. The
|
|
19
|
+
* transport does not emit or expect `mcp-session-id`. Each
|
|
20
|
+
* POST builds a fresh Server + transport, then closes them.
|
|
21
|
+
* - `enableJsonResponse: true` switches the transport off SSE
|
|
22
|
+
* and into pure request/response JSON. GET SSE opens are
|
|
23
|
+
* answered with `405`, which the client treats as "server
|
|
24
|
+
* does not offer SSE" and moves on (see `_startOrAuthSse`
|
|
25
|
+
* in the SDK client).
|
|
26
|
+
* - Each request gets its own cli **clone** via
|
|
22
27
|
* `baseCli.clone({ cwd, env, fs })`. The clone inherits the
|
|
23
28
|
* command tree but owns its own `{ cwd, env, fs }`, which is
|
|
24
29
|
* what `runCliTool` forwards into every action through
|
|
25
30
|
* `ctx.process.*` / `ctx.fs`.
|
|
26
31
|
*/
|
|
27
32
|
|
|
28
|
-
import { randomUUID } from "node:crypto";
|
|
29
|
-
import { Buffer } from "node:buffer";
|
|
30
33
|
import path from "node:path";
|
|
31
34
|
|
|
32
35
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
@@ -34,7 +37,6 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
|
|
34
37
|
import { Server as McpLowLevelServer } from "@modelcontextprotocol/sdk/server/index.js";
|
|
35
38
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
36
39
|
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
37
|
-
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
38
40
|
import { goke, type Goke, type GokeFs } from "goke";
|
|
39
41
|
import { describe, expect, it } from "vitest";
|
|
40
42
|
import { z } from "zod";
|
|
@@ -94,7 +96,7 @@ class InMemoryFs implements GokeFs {
|
|
|
94
96
|
* One cli definition, reused across tenants. Commands read / write
|
|
95
97
|
* through `ctx.fs` and resolve paths against `ctx.process.cwd`, so
|
|
96
98
|
* the *same* code runs per tenant but talks to a tenant-specific
|
|
97
|
-
* filesystem when invoked via the
|
|
99
|
+
* filesystem when invoked via the per-request clone below.
|
|
98
100
|
*/
|
|
99
101
|
function buildBaseCli(): Goke {
|
|
100
102
|
const cli = goke("notes-app");
|
|
@@ -122,9 +124,8 @@ function buildBaseCli(): Goke {
|
|
|
122
124
|
// ─── In-process multi-tenant fetch ────────────────────────────────
|
|
123
125
|
|
|
124
126
|
/**
|
|
125
|
-
* Per-tenant state resolved from the `x-tenant-id` header on
|
|
126
|
-
*
|
|
127
|
-
* env, and in-memory fs.
|
|
127
|
+
* Per-tenant state resolved from the `x-tenant-id` header on every
|
|
128
|
+
* POST. Each tenant gets its own cwd, env, and in-memory fs.
|
|
128
129
|
*/
|
|
129
130
|
interface TenantState {
|
|
130
131
|
cwd: string;
|
|
@@ -133,41 +134,40 @@ interface TenantState {
|
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
/**
|
|
136
|
-
* Build a `FetchLike` that
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* is keyed by `mcp-session-id
|
|
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.
|
|
137
|
+
* Build a `FetchLike` that handles each MCP POST with a fresh
|
|
138
|
+
* `WebStandardStreamableHTTPServerTransport` and cli clone.
|
|
139
|
+
* Tenant identity comes from `x-tenant-id` on every request.
|
|
140
|
+
* Nothing is keyed by `mcp-session-id`.
|
|
144
141
|
*/
|
|
145
142
|
function createMultiTenantFetch(options: {
|
|
146
143
|
baseCli: Goke;
|
|
147
144
|
resolveTenant: (tenantId: string) => TenantState;
|
|
148
145
|
}): {
|
|
149
146
|
fetch: FetchLike;
|
|
150
|
-
|
|
147
|
+
sessionHeaders: string[];
|
|
151
148
|
} {
|
|
152
149
|
const { baseCli, resolveTenant } = options;
|
|
153
|
-
const
|
|
150
|
+
const sessionHeaders: string[] = [];
|
|
154
151
|
|
|
155
152
|
const customFetch: FetchLike = async (url, init) => {
|
|
156
153
|
const method = (init?.method ?? "GET").toUpperCase();
|
|
157
154
|
const headers = new Headers(init?.headers);
|
|
155
|
+
const sessionHeader = headers.get("mcp-session-id");
|
|
156
|
+
if (sessionHeader) {
|
|
157
|
+
sessionHeaders.push(sessionHeader);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const origin = headers.get("origin");
|
|
161
|
+
if (origin && origin !== "http://in-memory-mcp.test") {
|
|
162
|
+
return new Response(null, { status: 403 });
|
|
163
|
+
}
|
|
158
164
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
// 405 as "server does not offer SSE" and moves on gracefully.
|
|
162
|
-
if (method === "GET") {
|
|
163
|
-
return new Response(null, { status: 405 });
|
|
165
|
+
if (method !== "POST") {
|
|
166
|
+
return new Response(null, { status: 405, headers: { Allow: "POST" } });
|
|
164
167
|
}
|
|
165
168
|
|
|
166
|
-
// Parse POST body once and hand it to the transport via
|
|
167
|
-
// `parsedBody` in `HandleRequestOptions` so we don't have to
|
|
168
|
-
// worry about Request body streams being single-use.
|
|
169
169
|
let parsedBody: unknown = undefined;
|
|
170
|
-
if (
|
|
170
|
+
if (init?.body != null) {
|
|
171
171
|
const rawBody = init.body;
|
|
172
172
|
const bodyText = typeof rawBody === "string"
|
|
173
173
|
? rawBody
|
|
@@ -177,35 +177,11 @@ function createMultiTenantFetch(options: {
|
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
179
|
|
|
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
180
|
const request = new Request(url.toString(), {
|
|
184
181
|
method,
|
|
185
182
|
headers,
|
|
186
183
|
});
|
|
187
184
|
|
|
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
185
|
const tenantId = headers.get("x-tenant-id");
|
|
210
186
|
if (!tenantId) {
|
|
211
187
|
return new Response("missing x-tenant-id header", { status: 401 });
|
|
@@ -225,29 +201,20 @@ function createMultiTenantFetch(options: {
|
|
|
225
201
|
addCliToolsToMcp({ cli: tenantCli, server: mcpServer });
|
|
226
202
|
|
|
227
203
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
228
|
-
sessionIdGenerator:
|
|
229
|
-
// Pure request/response — no SSE streaming to clean up.
|
|
204
|
+
sessionIdGenerator: undefined,
|
|
230
205
|
enableJsonResponse: true,
|
|
231
|
-
onsessioninitialized: (sid) => {
|
|
232
|
-
transports.set(sid, transport);
|
|
233
|
-
},
|
|
234
|
-
onsessionclosed: (sid) => {
|
|
235
|
-
transports.delete(sid);
|
|
236
|
-
},
|
|
237
206
|
});
|
|
238
207
|
|
|
239
|
-
transport.onclose = () => {
|
|
240
|
-
const sid = transport.sessionId;
|
|
241
|
-
if (sid) {
|
|
242
|
-
transports.delete(sid);
|
|
243
|
-
}
|
|
244
|
-
};
|
|
245
|
-
|
|
246
208
|
await mcpServer.connect(transport);
|
|
247
|
-
|
|
209
|
+
try {
|
|
210
|
+
return await transport.handleRequest(request, { parsedBody });
|
|
211
|
+
} finally {
|
|
212
|
+
await transport.close();
|
|
213
|
+
await mcpServer.close();
|
|
214
|
+
}
|
|
248
215
|
};
|
|
249
216
|
|
|
250
|
-
return { fetch: customFetch,
|
|
217
|
+
return { fetch: customFetch, sessionHeaders };
|
|
251
218
|
}
|
|
252
219
|
|
|
253
220
|
// ─── Tests ────────────────────────────────────────────────────────
|
|
@@ -267,7 +234,7 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
267
234
|
fs: new InMemoryFs(),
|
|
268
235
|
});
|
|
269
236
|
|
|
270
|
-
const { fetch: tenantFetch } = createMultiTenantFetch({
|
|
237
|
+
const { fetch: tenantFetch, sessionHeaders } = createMultiTenantFetch({
|
|
271
238
|
baseCli,
|
|
272
239
|
resolveTenant: (id) => {
|
|
273
240
|
const tenant = tenants.get(id);
|
|
@@ -276,8 +243,6 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
276
243
|
},
|
|
277
244
|
});
|
|
278
245
|
|
|
279
|
-
// The URL is a placeholder — the in-process fetch never looks
|
|
280
|
-
// at the host, just the method/headers/body.
|
|
281
246
|
const endpoint = new URL("http://in-memory-mcp.test/mcp");
|
|
282
247
|
|
|
283
248
|
async function connectTenant(tenantId: string): Promise<Client> {
|
|
@@ -297,7 +262,7 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
297
262
|
return client;
|
|
298
263
|
}
|
|
299
264
|
|
|
300
|
-
return { tenants, connectTenant };
|
|
265
|
+
return { tenants, connectTenant, sessionHeaders };
|
|
301
266
|
}
|
|
302
267
|
|
|
303
268
|
function firstTextBlock(result: Awaited<ReturnType<Client["callTool"]>>): string {
|
|
@@ -305,23 +270,18 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
305
270
|
return content.find((block) => block.type === "text")?.text ?? "";
|
|
306
271
|
}
|
|
307
272
|
|
|
308
|
-
it("routes each
|
|
309
|
-
const { tenants, connectTenant } = setupScenario();
|
|
273
|
+
it("routes each request to its own cli clone with tenant-specific cwd/env/fs", async () => {
|
|
274
|
+
const { tenants, connectTenant, sessionHeaders } = setupScenario();
|
|
310
275
|
|
|
311
276
|
const aliceClient = await connectTenant("tenant-a");
|
|
312
277
|
const bobClient = await connectTenant("tenant-b");
|
|
313
278
|
|
|
314
279
|
try {
|
|
315
|
-
// Each client sees the same tool catalog — it comes from the
|
|
316
|
-
// shared cli definition.
|
|
317
280
|
const aliceTools = (await aliceClient.listTools()).tools.map((t) => t.name).sort();
|
|
318
281
|
const bobTools = (await bobClient.listTools()).tools.map((t) => t.name).sort();
|
|
319
282
|
expect(aliceTools).toEqual(["load", "save"]);
|
|
320
283
|
expect(bobTools).toEqual(["load", "save"]);
|
|
321
284
|
|
|
322
|
-
// 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.
|
|
325
285
|
const aliceSave = await aliceClient.callTool({
|
|
326
286
|
name: "save",
|
|
327
287
|
arguments: { filename: "notes.txt", content: "alice-secret" },
|
|
@@ -336,7 +296,6 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
336
296
|
expect(firstTextBlock(bobSave)).toContain("/workspace-b/notes.txt");
|
|
337
297
|
expect(firstTextBlock(bobSave)).toContain("tenant-b");
|
|
338
298
|
|
|
339
|
-
// Each tenant reads back what it wrote.
|
|
340
299
|
const aliceLoad = await aliceClient.callTool({
|
|
341
300
|
name: "load",
|
|
342
301
|
arguments: { filename: "notes.txt" },
|
|
@@ -351,14 +310,13 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
351
310
|
expect(firstTextBlock(bobLoad)).toContain("bob-secret");
|
|
352
311
|
expect(firstTextBlock(bobLoad)).not.toContain("alice-secret");
|
|
353
312
|
|
|
354
|
-
// Sanity check: the underlying in-memory maps really are
|
|
355
|
-
// disjoint. Tenant A's fs only has tenant A's file.
|
|
356
313
|
const tenantAFs = tenants.get("tenant-a")!.fs;
|
|
357
314
|
const tenantBFs = tenants.get("tenant-b")!.fs;
|
|
358
315
|
expect([...tenantAFs.files.keys()]).toEqual(["/workspace-a/notes.txt"]);
|
|
359
316
|
expect([...tenantBFs.files.keys()]).toEqual(["/workspace-b/notes.txt"]);
|
|
360
317
|
expect(tenantAFs.files.get("/workspace-a/notes.txt")).toBe("alice-secret");
|
|
361
318
|
expect(tenantBFs.files.get("/workspace-b/notes.txt")).toBe("bob-secret");
|
|
319
|
+
expect(sessionHeaders).toEqual([]);
|
|
362
320
|
} finally {
|
|
363
321
|
await aliceClient.close();
|
|
364
322
|
await bobClient.close();
|
|
@@ -380,4 +338,25 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
380
338
|
await bobClient.close();
|
|
381
339
|
}
|
|
382
340
|
});
|
|
341
|
+
|
|
342
|
+
it("rejects a present Origin that is not allowed", async () => {
|
|
343
|
+
const { fetch } = createMultiTenantFetch({
|
|
344
|
+
baseCli: buildBaseCli(),
|
|
345
|
+
resolveTenant: () => {
|
|
346
|
+
throw new Error("tenant must not be resolved for a forbidden origin");
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const response = await fetch("http://in-memory-mcp.test/mcp", {
|
|
351
|
+
method: "POST",
|
|
352
|
+
headers: {
|
|
353
|
+
origin: "https://evil.example",
|
|
354
|
+
"x-tenant-id": "tenant-a",
|
|
355
|
+
"content-type": "application/json",
|
|
356
|
+
},
|
|
357
|
+
body: "{}",
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
expect(response.status).toBe(403);
|
|
361
|
+
});
|
|
383
362
|
});
|
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
|
-
*
|
|
33
|
-
*
|
|
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/cli-to-mcp.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
coerceBySchema,
|
|
21
21
|
extractJsonSchema,
|
|
22
22
|
GokeProcessExit,
|
|
23
|
+
schemaAcceptsOmittedValue,
|
|
23
24
|
type Command,
|
|
24
25
|
type Goke,
|
|
25
26
|
type GokeExecutionContext,
|
|
@@ -503,11 +504,18 @@ function createBinding(cli: Goke, command: Command, toolName: string): CliToolBi
|
|
|
503
504
|
}
|
|
504
505
|
}
|
|
505
506
|
|
|
507
|
+
// `--days <days>` means "value required if the flag is present". The flag
|
|
508
|
+
// itself is required only when a non-optional schema rejects `undefined`.
|
|
506
509
|
for (const option of options) {
|
|
507
510
|
const normalized = normalizeOptionSchema(option);
|
|
508
511
|
properties[option.name] = normalized.schema;
|
|
509
512
|
|
|
510
|
-
if (
|
|
513
|
+
if (
|
|
514
|
+
option.required
|
|
515
|
+
&& option.default === undefined
|
|
516
|
+
&& option.schema
|
|
517
|
+
&& !schemaAcceptsOmittedValue(option.schema)
|
|
518
|
+
) {
|
|
511
519
|
requiredNames.push(option.name);
|
|
512
520
|
}
|
|
513
521
|
|
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
|
|
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?: (
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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);
|