@goke/mcp 0.0.8 → 0.0.10

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.
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Tests for createMcpAction — turning a CLI into a stdio MCP server.
3
+ *
4
+ * Uses InMemoryTransport (via createTransport option) to avoid actual stdio.
5
+ * Simulates the goke runtime by setting matchedCommandName before calling the action.
6
+ */
7
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
9
+ import { goke, wrapJsonSchema } from "goke";
10
+ import { z } from "zod";
11
+ import { describe, expect, it } from "vitest";
12
+ import { createMcpAction } from "../cli-to-mcp.js";
13
+ function firstTextContent(result) {
14
+ const content = "content" in result ? result.content : [];
15
+ return content.find((entry) => entry.type === "text")?.text ?? "";
16
+ }
17
+ describe("createMcpAction", () => {
18
+ it("returns an action function", () => {
19
+ const cli = goke("test");
20
+ const action = createMcpAction({ cli });
21
+ expect(typeof action).toBe("function");
22
+ });
23
+ it("starts an MCP server exposing CLI commands, excluding the mcp command", async () => {
24
+ const cli = goke("test");
25
+ cli
26
+ .command("greet", "Say hello")
27
+ .option("--name <name>", z.string().describe("Person to greet"))
28
+ .action((options) => `Hello ${options.name}!`);
29
+ cli
30
+ .command("add", "Add numbers")
31
+ .option("--a <a>", z.number().describe("First"))
32
+ .option("--b <b>", z.number().describe("Second"))
33
+ .action((options) => ({ sum: options.a + options.b }));
34
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
35
+ cli.command("mcp", "Start MCP server").action(createMcpAction({
36
+ cli,
37
+ createTransport: () => serverTransport,
38
+ }));
39
+ // Simulate goke matching the "mcp" command (normally set by cli.parse())
40
+ cli.matchedCommandName = "mcp";
41
+ // Fire the action — starts the MCP server on the in-memory transport
42
+ const mcpCommand = cli.commands.find((c) => c.name === "mcp");
43
+ await mcpCommand.commandAction({});
44
+ const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
45
+ await client.connect(clientTransport);
46
+ try {
47
+ const tools = await client.listTools();
48
+ const toolNames = tools.tools.map((t) => t.name).sort();
49
+ expect(toolNames).toEqual(["add", "greet"]);
50
+ const greetResult = await client.callTool({
51
+ name: "greet",
52
+ arguments: { name: "World" },
53
+ });
54
+ expect(firstTextContent(greetResult)).toBe("Hello World!");
55
+ const addResult = await client.callTool({
56
+ name: "add",
57
+ arguments: { a: 3, b: 7 },
58
+ });
59
+ expect(firstTextContent(addResult)).toBe('{\n "sum": 10\n}');
60
+ }
61
+ finally {
62
+ await client.close();
63
+ }
64
+ });
65
+ it("composes user commandFilter with auto-exclusion", async () => {
66
+ const cli = goke("test");
67
+ cli.command("public-cmd", "Public command").action(() => "public");
68
+ cli.command("secret-cmd", "Secret command").action(() => "secret");
69
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
70
+ cli.command("mcp", "Start MCP server").action(createMcpAction({
71
+ cli,
72
+ commandFilter: (name) => name !== "secret-cmd",
73
+ createTransport: () => serverTransport,
74
+ }));
75
+ cli.matchedCommandName = "mcp";
76
+ const mcpCommand = cli.commands.find((c) => c.name === "mcp");
77
+ await mcpCommand.commandAction({});
78
+ const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
79
+ await client.connect(clientTransport);
80
+ try {
81
+ const tools = await client.listTools();
82
+ const toolNames = tools.tools.map((t) => t.name).sort();
83
+ // Both "mcp" (auto-excluded) and "secret-cmd" (user filter) excluded
84
+ expect(toolNames).toEqual(["public-cmd"]);
85
+ }
86
+ finally {
87
+ await client.close();
88
+ }
89
+ });
90
+ it("works with multi-word command names", async () => {
91
+ const cli = goke("test");
92
+ cli
93
+ .command("db migrate", "Run migrations")
94
+ .action(() => "migrated");
95
+ cli
96
+ .command("db seed", "Seed database")
97
+ .action(() => "seeded");
98
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
99
+ cli.command("serve mcp", "Start MCP server").action(createMcpAction({
100
+ cli,
101
+ createTransport: () => serverTransport,
102
+ }));
103
+ cli.matchedCommandName = "serve mcp";
104
+ const mcpCommand = cli.commands.find((c) => c.name === "serve mcp");
105
+ await mcpCommand.commandAction({});
106
+ const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
107
+ await client.connect(clientTransport);
108
+ try {
109
+ const tools = await client.listTools();
110
+ const toolNames = tools.tools.map((t) => t.name).sort();
111
+ expect(toolNames).toEqual(["db_migrate", "db_seed"]);
112
+ const migrateResult = await client.callTool({
113
+ name: "db_migrate",
114
+ arguments: {},
115
+ });
116
+ expect(firstTextContent(migrateResult)).toBe("migrated");
117
+ }
118
+ finally {
119
+ await client.close();
120
+ }
121
+ });
122
+ it("end-to-end: MCP client connects, discovers tools with schemas, calls tools, handles errors", async () => {
123
+ const cli = goke("my-app");
124
+ // String option command
125
+ cli
126
+ .command("search", "Search for items")
127
+ .option("--query <query>", z.string().describe("Search query"))
128
+ .option("--limit [limit]", z.number().default(10).describe("Max results"))
129
+ .action((options) => {
130
+ return { results: [`result for "${options.query}"`], limit: options.limit };
131
+ });
132
+ // Boolean flag + positional arg command
133
+ cli
134
+ .command("deploy <env>", "Deploy to environment")
135
+ .option("--dry-run", z.boolean().default(false).describe("Simulate deployment"))
136
+ .action((env, options) => {
137
+ return options.dryRun ? `dry-run deploy to ${env}` : `deployed to ${env}`;
138
+ });
139
+ // Command that returns a CallToolResult directly
140
+ cli
141
+ .command("status", "Get system status")
142
+ .action(() => ({
143
+ content: [{ type: "text", text: "all systems operational" }],
144
+ }));
145
+ // Command that throws an error (should be caught and returned as isError)
146
+ cli
147
+ .command("fail", "Always fails")
148
+ .action(() => {
149
+ throw new Error("something went wrong");
150
+ });
151
+ // Wrapped JSON schema command.
152
+ // wrapJsonSchema produces a StandardJSONSchemaV1 with `unknown` output, so
153
+ // values are cast explicitly inside the action.
154
+ cli
155
+ .command("config set", "Set a config value")
156
+ .option("--key <key>", wrapJsonSchema({ type: "string", description: "Config key" }))
157
+ .option("--value <value>", wrapJsonSchema({ type: "string", description: "Config value" }))
158
+ .action((options) => {
159
+ return `set ${String(options.key)} = ${String(options.value)}`;
160
+ });
161
+ // Commands without actions (should NOT appear as tools)
162
+ cli.command("no-action", "This has no action handler");
163
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
164
+ cli.command("mcp", "Start MCP server").action(createMcpAction({
165
+ cli,
166
+ serverName: "my-app-mcp",
167
+ serverVersion: "2.5.0",
168
+ createTransport: () => serverTransport,
169
+ }));
170
+ cli.matchedCommandName = "mcp";
171
+ const mcpCommand = cli.commands.find((c) => c.name === "mcp");
172
+ await mcpCommand.commandAction({});
173
+ const client = new Client({ name: "e2e-test-client", version: "1.0.0" }, { capabilities: {} });
174
+ await client.connect(clientTransport);
175
+ try {
176
+ // ── Server info ──
177
+ const serverInfo = client.getServerVersion();
178
+ expect(serverInfo).toMatchObject({ name: "my-app-mcp", version: "2.5.0" });
179
+ // ── Tool discovery ──
180
+ const tools = await client.listTools();
181
+ const toolNames = tools.tools.map((t) => t.name).sort();
182
+ // "mcp" auto-excluded, "no-action" has no handler → not mounted
183
+ expect(toolNames).toEqual(["config_set", "deploy", "fail", "search", "status"]);
184
+ // ── Verify schemas are propagated ──
185
+ const searchTool = tools.tools.find((t) => t.name === "search");
186
+ expect(searchTool.description).toBe("Search for items");
187
+ expect(searchTool.inputSchema.properties).toHaveProperty("query");
188
+ expect(searchTool.inputSchema.properties).toHaveProperty("limit");
189
+ expect(searchTool.inputSchema.required).toEqual(["query"]);
190
+ const deployTool = tools.tools.find((t) => t.name === "deploy");
191
+ expect(deployTool.inputSchema.properties).toHaveProperty("env");
192
+ expect(deployTool.inputSchema.properties).toHaveProperty("dryRun");
193
+ expect(deployTool.inputSchema.required).toEqual(["env"]);
194
+ // ── Call tool with schema-based options ──
195
+ const searchResult = await client.callTool({
196
+ name: "search",
197
+ arguments: { query: "hello", limit: 5 },
198
+ });
199
+ expect(firstTextContent(searchResult)).toBe('{\n "results": [\n "result for \\"hello\\""\n ],\n "limit": 5\n}');
200
+ // ── Call tool with positional args ──
201
+ const deployResult = await client.callTool({
202
+ name: "deploy",
203
+ arguments: { env: "production", dryRun: true },
204
+ });
205
+ expect(firstTextContent(deployResult)).toBe("dry-run deploy to production");
206
+ // ── Call tool that returns a raw CallToolResult ──
207
+ const statusResult = await client.callTool({
208
+ name: "status",
209
+ arguments: {},
210
+ });
211
+ expect(firstTextContent(statusResult)).toBe("all systems operational");
212
+ // ── Call tool that throws → error is caught and returned as isError ──
213
+ const failResult = await client.callTool({
214
+ name: "fail",
215
+ arguments: {},
216
+ });
217
+ expect(failResult.isError).toBe(true);
218
+ expect(firstTextContent(failResult)).toBe("something went wrong");
219
+ // ── Call tool with multi-word command name ──
220
+ const configResult = await client.callTool({
221
+ name: "config_set",
222
+ arguments: { key: "theme", value: "dark" },
223
+ });
224
+ expect(firstTextContent(configResult)).toBe("set theme = dark");
225
+ // ── Call nonexistent tool → MCP error ──
226
+ await expect(client.callTool({ name: "nonexistent", arguments: {} })).rejects.toThrow(/not found/i);
227
+ }
228
+ finally {
229
+ await client.close();
230
+ }
231
+ });
232
+ it("returns empty tool list when only the mcp command exists", async () => {
233
+ const cli = goke("empty-app");
234
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
235
+ cli.command("mcp", "Start MCP server").action(createMcpAction({
236
+ cli,
237
+ createTransport: () => serverTransport,
238
+ }));
239
+ cli.matchedCommandName = "mcp";
240
+ const mcpCommand = cli.commands.find((c) => c.name === "mcp");
241
+ await mcpCommand.commandAction({});
242
+ const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
243
+ await client.connect(clientTransport);
244
+ try {
245
+ const tools = await client.listTools();
246
+ expect(tools.tools).toEqual([]);
247
+ }
248
+ finally {
249
+ await client.close();
250
+ }
251
+ });
252
+ it("exposes all commands when matchedCommandName is not set", async () => {
253
+ const cli = goke("test");
254
+ cli.command("ping", "Ping").action(() => "pong");
255
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
256
+ cli.command("mcp", "Start MCP server").action(createMcpAction({
257
+ cli,
258
+ createTransport: () => serverTransport,
259
+ }));
260
+ // Do NOT set matchedCommandName — simulates programmatic invocation
261
+ // without cli.parse(). All commands including "mcp" should be exposed.
262
+ const mcpCommand = cli.commands.find((c) => c.name === "mcp");
263
+ await mcpCommand.commandAction({});
264
+ const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
265
+ await client.connect(clientTransport);
266
+ try {
267
+ const tools = await client.listTools();
268
+ const toolNames = tools.tools.map((t) => t.name).sort();
269
+ // Without matchedCommandName, auto-exclusion can't kick in
270
+ expect(toolNames).toEqual(["mcp", "ping"]);
271
+ }
272
+ finally {
273
+ await client.close();
274
+ }
275
+ });
276
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Multi-tenant remote-MCP test.
3
+ *
4
+ * Proves that one goke cli exposed over the MCP streamable-HTTP
5
+ * transport can serve multiple concurrent users with fully isolated
6
+ * state (in-memory fs + cwd + env) — no shared host process stdio,
7
+ * no cross-tenant leaks.
8
+ *
9
+ * Wiring choices worth calling out:
10
+ *
11
+ * - `WebStandardStreamableHTTPServerTransport` from the MCP SDK
12
+ * accepts a Web-Standard `Request` and returns a `Response`.
13
+ * That means we can drive it **in-process** through the client
14
+ * transport's `fetch` hook without ever binding a TCP socket
15
+ * or spinning up `node:http` / Express. Same wire protocol,
16
+ * zero sockets.
17
+ * - `enableJsonResponse: true` switches the transport off SSE and
18
+ * into pure request/response JSON. GET SSE opens are answered
19
+ * with `405`, which the client treats as "server does not offer
20
+ * SSE" and moves on (see `_startOrAuthSse` in the SDK client).
21
+ * - Each session gets its own cli **clone** via
22
+ * `baseCli.clone({ cwd, env, fs })`. The clone inherits the
23
+ * command tree but owns its own `{ cwd, env, fs }`, which is
24
+ * what `runCliTool` forwards into every action through
25
+ * `ctx.process.*` / `ctx.fs`.
26
+ */
27
+ export {};
28
+ //# sourceMappingURL=http-multi-tenant.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http-multi-tenant.test.d.ts","sourceRoot":"","sources":["../../src/__test__/http-multi-tenant.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG"}
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Multi-tenant remote-MCP test.
3
+ *
4
+ * Proves that one goke cli exposed over the MCP streamable-HTTP
5
+ * transport can serve multiple concurrent users with fully isolated
6
+ * state (in-memory fs + cwd + env) — no shared host process stdio,
7
+ * no cross-tenant leaks.
8
+ *
9
+ * Wiring choices worth calling out:
10
+ *
11
+ * - `WebStandardStreamableHTTPServerTransport` from the MCP SDK
12
+ * accepts a Web-Standard `Request` and returns a `Response`.
13
+ * That means we can drive it **in-process** through the client
14
+ * transport's `fetch` hook without ever binding a TCP socket
15
+ * or spinning up `node:http` / Express. Same wire protocol,
16
+ * zero sockets.
17
+ * - `enableJsonResponse: true` switches the transport off SSE and
18
+ * into pure request/response JSON. GET SSE opens are answered
19
+ * with `405`, which the client treats as "server does not offer
20
+ * SSE" and moves on (see `_startOrAuthSse` in the SDK client).
21
+ * - Each session gets its own cli **clone** via
22
+ * `baseCli.clone({ cwd, env, fs })`. The clone inherits the
23
+ * command tree but owns its own `{ cwd, env, fs }`, which is
24
+ * what `runCliTool` forwards into every action through
25
+ * `ctx.process.*` / `ctx.fs`.
26
+ */
27
+ import { randomUUID } from "node:crypto";
28
+ import path from "node:path";
29
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
30
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
31
+ import { Server as McpLowLevelServer } from "@modelcontextprotocol/sdk/server/index.js";
32
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
33
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
34
+ import { goke } from "goke";
35
+ import { describe, expect, it } from "vitest";
36
+ import { z } from "zod";
37
+ import { addCliToolsToMcp } from "../cli-to-mcp.js";
38
+ // ─── Minimal in-memory fs ─────────────────────────────────────────
39
+ /**
40
+ * Dead-simple `GokeFs` backed by a `Map<string, string>`.
41
+ *
42
+ * Implements only the methods the cli commands in this test
43
+ * actually call (`writeFile`, `readFile`, `mkdir`). Every other
44
+ * method throws so accidental real-fs usage would fail loudly.
45
+ */
46
+ const notImplemented = (name) => () => {
47
+ throw new Error(`InMemoryFs.${name} not implemented for this test`);
48
+ };
49
+ class InMemoryFs {
50
+ files = new Map();
51
+ writeFile = async (filePath, data) => {
52
+ const key = String(filePath);
53
+ const text = typeof data === "string"
54
+ ? data
55
+ : new TextDecoder("utf-8").decode(data);
56
+ this.files.set(key, text);
57
+ };
58
+ readFile = async (filePath) => {
59
+ const key = String(filePath);
60
+ const content = this.files.get(key);
61
+ if (content === undefined) {
62
+ throw new Error(`ENOENT: ${key}`);
63
+ }
64
+ return content;
65
+ };
66
+ mkdir = async () => undefined;
67
+ appendFile = notImplemented("appendFile");
68
+ chmod = notImplemented("chmod");
69
+ copyFile = notImplemented("copyFile");
70
+ link = notImplemented("link");
71
+ readlink = notImplemented("readlink");
72
+ realpath = notImplemented("realpath");
73
+ rename = notImplemented("rename");
74
+ rm = notImplemented("rm");
75
+ symlink = notImplemented("symlink");
76
+ utimes = notImplemented("utimes");
77
+ }
78
+ // ─── Shared cli definition ────────────────────────────────────────
79
+ /**
80
+ * One cli definition, reused across tenants. Commands read / write
81
+ * through `ctx.fs` and resolve paths against `ctx.process.cwd`, so
82
+ * the *same* code runs per tenant but talks to a tenant-specific
83
+ * filesystem when invoked via the session-scoped clone below.
84
+ */
85
+ function buildBaseCli() {
86
+ const cli = goke("notes-app");
87
+ cli
88
+ .command("save <filename>", "Save content to a file in the tenant workspace")
89
+ .option("--content <content>", z.string().describe("File content"))
90
+ .action(async (filename, options, ctx) => {
91
+ const full = path.posix.join(ctx.process.cwd, filename);
92
+ await ctx.fs.writeFile(full, options.content);
93
+ return { saved: full, tenant: ctx.process.env.TENANT_ID };
94
+ });
95
+ cli
96
+ .command("load <filename>", "Read a file from the tenant workspace")
97
+ .action(async (filename, _options, ctx) => {
98
+ const full = path.posix.join(ctx.process.cwd, filename);
99
+ const text = await ctx.fs.readFile(full, "utf8");
100
+ return { path: full, text, tenant: ctx.process.env.TENANT_ID };
101
+ });
102
+ return cli;
103
+ }
104
+ /**
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.
113
+ */
114
+ function createMultiTenantFetch(options) {
115
+ const { baseCli, resolveTenant } = options;
116
+ const transports = new Map();
117
+ const customFetch = async (url, init) => {
118
+ const method = (init?.method ?? "GET").toUpperCase();
119
+ const headers = new Headers(init?.headers);
120
+ // Pure request/response mode: tell the client there's no SSE
121
+ // available on GET. `_startOrAuthSse` in the SDK client treats
122
+ // 405 as "server does not offer SSE" and moves on gracefully.
123
+ if (method === "GET") {
124
+ return new Response(null, { status: 405 });
125
+ }
126
+ // Parse POST body once and hand it to the transport via
127
+ // `parsedBody` in `HandleRequestOptions` so we don't have to
128
+ // worry about Request body streams being single-use.
129
+ let parsedBody = undefined;
130
+ if (method === "POST" && init?.body != null) {
131
+ const rawBody = init.body;
132
+ const bodyText = typeof rawBody === "string"
133
+ ? rawBody
134
+ : await new Response(rawBody).text();
135
+ if (bodyText) {
136
+ parsedBody = JSON.parse(bodyText);
137
+ }
138
+ }
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
+ const request = new Request(url.toString(), {
143
+ method,
144
+ headers,
145
+ });
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
+ const tenantId = headers.get("x-tenant-id");
162
+ if (!tenantId) {
163
+ return new Response("missing x-tenant-id header", { status: 401 });
164
+ }
165
+ const tenant = resolveTenant(tenantId);
166
+ const tenantCli = baseCli.clone({
167
+ cwd: tenant.cwd,
168
+ env: { ...tenant.env, TENANT_ID: tenantId },
169
+ fs: tenant.fs,
170
+ });
171
+ const mcpServer = new McpLowLevelServer({ name: "notes-app-mcp", version: "1.0.0" }, { capabilities: {} });
172
+ addCliToolsToMcp({ cli: tenantCli, server: mcpServer });
173
+ const transport = new WebStandardStreamableHTTPServerTransport({
174
+ sessionIdGenerator: () => randomUUID(),
175
+ // Pure request/response — no SSE streaming to clean up.
176
+ enableJsonResponse: true,
177
+ onsessioninitialized: (sid) => {
178
+ transports.set(sid, transport);
179
+ },
180
+ onsessionclosed: (sid) => {
181
+ transports.delete(sid);
182
+ },
183
+ });
184
+ transport.onclose = () => {
185
+ const sid = transport.sessionId;
186
+ if (sid) {
187
+ transports.delete(sid);
188
+ }
189
+ };
190
+ await mcpServer.connect(transport);
191
+ return transport.handleRequest(request, { parsedBody });
192
+ };
193
+ return { fetch: customFetch, transports };
194
+ }
195
+ // ─── Tests ────────────────────────────────────────────────────────
196
+ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () => {
197
+ function setupScenario() {
198
+ const baseCli = buildBaseCli();
199
+ const tenants = new Map();
200
+ tenants.set("tenant-a", {
201
+ cwd: "/workspace-a",
202
+ env: { ROLE: "writer" },
203
+ fs: new InMemoryFs(),
204
+ });
205
+ tenants.set("tenant-b", {
206
+ cwd: "/workspace-b",
207
+ env: { ROLE: "reader" },
208
+ fs: new InMemoryFs(),
209
+ });
210
+ const { fetch: tenantFetch } = createMultiTenantFetch({
211
+ baseCli,
212
+ resolveTenant: (id) => {
213
+ const tenant = tenants.get(id);
214
+ if (!tenant)
215
+ throw new Error(`unknown tenant ${id}`);
216
+ return tenant;
217
+ },
218
+ });
219
+ // The URL is a placeholder — the in-process fetch never looks
220
+ // at the host, just the method/headers/body.
221
+ const endpoint = new URL("http://in-memory-mcp.test/mcp");
222
+ async function connectTenant(tenantId) {
223
+ const transport = new StreamableHTTPClientTransport(endpoint, {
224
+ fetch: tenantFetch,
225
+ requestInit: {
226
+ headers: {
227
+ "x-tenant-id": tenantId,
228
+ },
229
+ },
230
+ });
231
+ const client = new Client({ name: `${tenantId}-client`, version: "1.0.0" }, { capabilities: {} });
232
+ await client.connect(transport);
233
+ return client;
234
+ }
235
+ return { tenants, connectTenant };
236
+ }
237
+ function firstTextBlock(result) {
238
+ const content = result.content ?? [];
239
+ return content.find((block) => block.type === "text")?.text ?? "";
240
+ }
241
+ it("routes each session to its own cli clone with tenant-specific cwd/env/fs", async () => {
242
+ const { tenants, connectTenant } = setupScenario();
243
+ const aliceClient = await connectTenant("tenant-a");
244
+ const bobClient = await connectTenant("tenant-b");
245
+ try {
246
+ // Each client sees the same tool catalog — it comes from the
247
+ // shared cli definition.
248
+ const aliceTools = (await aliceClient.listTools()).tools.map((t) => t.name).sort();
249
+ const bobTools = (await bobClient.listTools()).tools.map((t) => t.name).sort();
250
+ expect(aliceTools).toEqual(["load", "save"]);
251
+ expect(bobTools).toEqual(["load", "save"]);
252
+ // 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.
255
+ const aliceSave = await aliceClient.callTool({
256
+ name: "save",
257
+ arguments: { filename: "notes.txt", content: "alice-secret" },
258
+ });
259
+ const bobSave = await bobClient.callTool({
260
+ name: "save",
261
+ arguments: { filename: "notes.txt", content: "bob-secret" },
262
+ });
263
+ expect(firstTextBlock(aliceSave)).toContain("/workspace-a/notes.txt");
264
+ expect(firstTextBlock(aliceSave)).toContain("tenant-a");
265
+ expect(firstTextBlock(bobSave)).toContain("/workspace-b/notes.txt");
266
+ expect(firstTextBlock(bobSave)).toContain("tenant-b");
267
+ // Each tenant reads back what it wrote.
268
+ const aliceLoad = await aliceClient.callTool({
269
+ name: "load",
270
+ arguments: { filename: "notes.txt" },
271
+ });
272
+ const bobLoad = await bobClient.callTool({
273
+ name: "load",
274
+ arguments: { filename: "notes.txt" },
275
+ });
276
+ expect(firstTextBlock(aliceLoad)).toContain("alice-secret");
277
+ expect(firstTextBlock(aliceLoad)).not.toContain("bob-secret");
278
+ expect(firstTextBlock(bobLoad)).toContain("bob-secret");
279
+ expect(firstTextBlock(bobLoad)).not.toContain("alice-secret");
280
+ // Sanity check: the underlying in-memory maps really are
281
+ // disjoint. Tenant A's fs only has tenant A's file.
282
+ const tenantAFs = tenants.get("tenant-a").fs;
283
+ const tenantBFs = tenants.get("tenant-b").fs;
284
+ expect([...tenantAFs.files.keys()]).toEqual(["/workspace-a/notes.txt"]);
285
+ expect([...tenantBFs.files.keys()]).toEqual(["/workspace-b/notes.txt"]);
286
+ expect(tenantAFs.files.get("/workspace-a/notes.txt")).toBe("alice-secret");
287
+ expect(tenantBFs.files.get("/workspace-b/notes.txt")).toBe("bob-secret");
288
+ }
289
+ finally {
290
+ await aliceClient.close();
291
+ await bobClient.close();
292
+ }
293
+ });
294
+ it("raises a tool error when a tenant reads a file it never wrote", async () => {
295
+ const { connectTenant } = setupScenario();
296
+ const bobClient = await connectTenant("tenant-b");
297
+ try {
298
+ const result = await bobClient.callTool({
299
+ name: "load",
300
+ arguments: { filename: "does-not-exist.txt" },
301
+ });
302
+ expect(result.isError).toBe(true);
303
+ expect(firstTextBlock(result)).toMatch(/ENOENT/);
304
+ }
305
+ finally {
306
+ await bobClient.close();
307
+ }
308
+ });
309
+ });
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
9
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
9
10
  import { type Goke } from "goke";
10
11
  export interface AddCliToolsToMcpOptions {
11
12
  cli: Goke;
@@ -13,5 +14,32 @@ export interface AddCliToolsToMcpOptions {
13
14
  commandFilter?: (commandName: string) => boolean;
14
15
  sanitizeToolName?: (commandName: string) => string;
15
16
  }
17
+ export interface CreateMcpActionOptions {
18
+ /** The CLI instance whose commands will be exposed as MCP tools */
19
+ cli: Goke;
20
+ /** Additional filter for which commands to expose. The MCP command itself is always excluded. */
21
+ commandFilter?: (commandName: string) => boolean;
22
+ /** Custom tool name sanitizer */
23
+ sanitizeToolName?: (commandName: string) => string;
24
+ /** MCP server name. Defaults to the CLI name or 'cli-mcp-server' */
25
+ serverName?: string;
26
+ /** MCP server version. Defaults to '1.0.0' */
27
+ serverVersion?: string;
28
+ /** Custom transport factory. Defaults to StdioServerTransport (stdin/stdout). */
29
+ createTransport?: () => Transport | Promise<Transport>;
30
+ }
31
+ /**
32
+ * Create a goke action callback that starts an MCP server over stdio.
33
+ *
34
+ * Exposes all CLI commands as MCP tools, automatically excluding the
35
+ * command this action is attached to.
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * cli.command('mcp', 'Start MCP server over stdio')
40
+ * .action(createMcpAction({ cli }))
41
+ * ```
42
+ */
43
+ export declare function createMcpAction(options: CreateMcpActionOptions): (...args: any[]) => Promise<void>;
16
44
  export declare function addCliToolsToMcp(options: AddCliToolsToMcpOptions): void;
17
45
  //# sourceMappingURL=cli-to-mcp.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cli-to-mcp.d.ts","sourceRoot":"","sources":["../src/cli-to-mcp.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AASxE,OAAO,EAAmD,KAAK,IAAI,EAA6B,MAAM,MAAM,CAAC;AAwD7G,MAAM,WAAW,uBAAuB;IACtC,GAAG,EAAE,IAAI,CAAC;IACV,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,aAAa,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC;IACjD,gBAAgB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,MAAM,CAAC;CACpD;AA4XD,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB,GAAG,IAAI,CAyCvE"}
1
+ {"version":3,"file":"cli-to-mcp.d.ts","sourceRoot":"","sources":["../src/cli-to-mcp.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AACxE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+CAA+C,CAAC;AAS/E,OAAO,EAKL,KAAK,IAAI,EAIV,MAAM,MAAM,CAAC;AAwFd,MAAM,WAAW,uBAAuB;IACtC,GAAG,EAAE,IAAI,CAAC;IACV,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,aAAa,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC;IACjD,gBAAgB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,MAAM,CAAC;CACpD;AAqfD,MAAM,WAAW,sBAAsB;IACrC,mEAAmE;IACnE,GAAG,EAAE,IAAI,CAAC;IACV,iGAAiG;IACjG,aAAa,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC;IACjD,iCAAiC;IACjC,gBAAgB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,MAAM,CAAC;IACnD,oEAAoE;IACpE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8CAA8C;IAC9C,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,iFAAiF;IACjF,eAAe,CAAC,EAAE,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;CACxD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,sBAAsB,GAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAsClG;AAED,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB,GAAG,IAAI,CAyCvE"}