@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.
- package/README.md +295 -29
- package/dist/__test__/add-cli-tools-to-mcp.test.js +223 -2
- package/dist/__test__/create-mcp-action.test.d.ts +8 -0
- package/dist/__test__/create-mcp-action.test.d.ts.map +1 -0
- package/dist/__test__/create-mcp-action.test.js +276 -0
- package/dist/__test__/http-multi-tenant.test.d.ts +28 -0
- package/dist/__test__/http-multi-tenant.test.d.ts.map +1 -0
- package/dist/__test__/http-multi-tenant.test.js +309 -0
- package/dist/cli-to-mcp.d.ts +28 -0
- package/dist/cli-to-mcp.d.ts.map +1 -1
- package/dist/cli-to-mcp.js +162 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/src/__test__/add-cli-tools-to-mcp.test.ts +306 -7
- package/src/__test__/create-mcp-action.test.ts +355 -0
- package/src/__test__/http-multi-tenant.test.ts +383 -0
- package/src/cli-to-mcp.ts +236 -6
- package/src/index.ts +2 -2
|
@@ -0,0 +1,383 @@
|
|
|
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
|
+
|
|
28
|
+
import { randomUUID } from "node:crypto";
|
|
29
|
+
import { Buffer } from "node:buffer";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
|
|
32
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
33
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
34
|
+
import { Server as McpLowLevelServer } from "@modelcontextprotocol/sdk/server/index.js";
|
|
35
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
36
|
+
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
37
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
38
|
+
import { goke, type Goke, type GokeFs } from "goke";
|
|
39
|
+
import { describe, expect, it } from "vitest";
|
|
40
|
+
import { z } from "zod";
|
|
41
|
+
|
|
42
|
+
import { addCliToolsToMcp } from "../cli-to-mcp.js";
|
|
43
|
+
|
|
44
|
+
// ─── Minimal in-memory fs ─────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Dead-simple `GokeFs` backed by a `Map<string, string>`.
|
|
48
|
+
*
|
|
49
|
+
* Implements only the methods the cli commands in this test
|
|
50
|
+
* actually call (`writeFile`, `readFile`, `mkdir`). Every other
|
|
51
|
+
* method throws so accidental real-fs usage would fail loudly.
|
|
52
|
+
*/
|
|
53
|
+
const notImplemented = (name: string) => () => {
|
|
54
|
+
throw new Error(`InMemoryFs.${name} not implemented for this test`);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
class InMemoryFs implements GokeFs {
|
|
58
|
+
readonly files = new Map<string, string>();
|
|
59
|
+
|
|
60
|
+
writeFile: GokeFs["writeFile"] = async (filePath, data) => {
|
|
61
|
+
const key = String(filePath);
|
|
62
|
+
const text = typeof data === "string"
|
|
63
|
+
? data
|
|
64
|
+
: new TextDecoder("utf-8").decode(data);
|
|
65
|
+
this.files.set(key, text);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
readFile: GokeFs["readFile"] = async (filePath) => {
|
|
69
|
+
const key = String(filePath);
|
|
70
|
+
const content = this.files.get(key);
|
|
71
|
+
if (content === undefined) {
|
|
72
|
+
throw new Error(`ENOENT: ${key}`);
|
|
73
|
+
}
|
|
74
|
+
return content;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
mkdir: GokeFs["mkdir"] = async () => undefined;
|
|
78
|
+
|
|
79
|
+
appendFile: GokeFs["appendFile"] = notImplemented("appendFile");
|
|
80
|
+
chmod: GokeFs["chmod"] = notImplemented("chmod");
|
|
81
|
+
copyFile: GokeFs["copyFile"] = notImplemented("copyFile");
|
|
82
|
+
link: GokeFs["link"] = notImplemented("link");
|
|
83
|
+
readlink: GokeFs["readlink"] = notImplemented("readlink");
|
|
84
|
+
realpath: GokeFs["realpath"] = notImplemented("realpath");
|
|
85
|
+
rename: GokeFs["rename"] = notImplemented("rename");
|
|
86
|
+
rm: GokeFs["rm"] = notImplemented("rm");
|
|
87
|
+
symlink: GokeFs["symlink"] = notImplemented("symlink");
|
|
88
|
+
utimes: GokeFs["utimes"] = notImplemented("utimes");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Shared cli definition ────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* One cli definition, reused across tenants. Commands read / write
|
|
95
|
+
* through `ctx.fs` and resolve paths against `ctx.process.cwd`, so
|
|
96
|
+
* the *same* code runs per tenant but talks to a tenant-specific
|
|
97
|
+
* filesystem when invoked via the session-scoped clone below.
|
|
98
|
+
*/
|
|
99
|
+
function buildBaseCli(): Goke {
|
|
100
|
+
const cli = goke("notes-app");
|
|
101
|
+
|
|
102
|
+
cli
|
|
103
|
+
.command("save <filename>", "Save content to a file in the tenant workspace")
|
|
104
|
+
.option("--content <content>", z.string().describe("File content"))
|
|
105
|
+
.action(async (filename: string, options: { content: string }, ctx) => {
|
|
106
|
+
const full = path.posix.join(ctx.process.cwd, filename);
|
|
107
|
+
await ctx.fs.writeFile(full, options.content);
|
|
108
|
+
return { saved: full, tenant: ctx.process.env.TENANT_ID };
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
cli
|
|
112
|
+
.command("load <filename>", "Read a file from the tenant workspace")
|
|
113
|
+
.action(async (filename: string, _options, ctx) => {
|
|
114
|
+
const full = path.posix.join(ctx.process.cwd, filename);
|
|
115
|
+
const text = await ctx.fs.readFile(full, "utf8");
|
|
116
|
+
return { path: full, text, tenant: ctx.process.env.TENANT_ID };
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return cli;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── In-process multi-tenant fetch ────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Per-tenant state resolved from the `x-tenant-id` header on a
|
|
126
|
+
* session-initialization request. Each tenant gets its own cwd,
|
|
127
|
+
* env, and in-memory fs.
|
|
128
|
+
*/
|
|
129
|
+
interface TenantState {
|
|
130
|
+
cwd: string;
|
|
131
|
+
env: Record<string, string>;
|
|
132
|
+
fs: InMemoryFs;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Build a `FetchLike` that routes MCP streamable-HTTP traffic into
|
|
137
|
+
* in-process session-scoped `WebStandardStreamableHTTPServerTransport`
|
|
138
|
+
* instances. One transport + one cli clone per session. Each session
|
|
139
|
+
* is keyed by `mcp-session-id`; initialization requests pick a tenant
|
|
140
|
+
* via the `x-tenant-id` header.
|
|
141
|
+
*
|
|
142
|
+
* Returns both the custom fetch and the transports map so tests can
|
|
143
|
+
* inspect session state if needed.
|
|
144
|
+
*/
|
|
145
|
+
function createMultiTenantFetch(options: {
|
|
146
|
+
baseCli: Goke;
|
|
147
|
+
resolveTenant: (tenantId: string) => TenantState;
|
|
148
|
+
}): {
|
|
149
|
+
fetch: FetchLike;
|
|
150
|
+
transports: Map<string, WebStandardStreamableHTTPServerTransport>;
|
|
151
|
+
} {
|
|
152
|
+
const { baseCli, resolveTenant } = options;
|
|
153
|
+
const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
|
|
154
|
+
|
|
155
|
+
const customFetch: FetchLike = async (url, init) => {
|
|
156
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
157
|
+
const headers = new Headers(init?.headers);
|
|
158
|
+
|
|
159
|
+
// Pure request/response mode: tell the client there's no SSE
|
|
160
|
+
// available on GET. `_startOrAuthSse` in the SDK client treats
|
|
161
|
+
// 405 as "server does not offer SSE" and moves on gracefully.
|
|
162
|
+
if (method === "GET") {
|
|
163
|
+
return new Response(null, { status: 405 });
|
|
164
|
+
}
|
|
165
|
+
|
|
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
|
+
let parsedBody: unknown = undefined;
|
|
170
|
+
if (method === "POST" && init?.body != null) {
|
|
171
|
+
const rawBody = init.body;
|
|
172
|
+
const bodyText = typeof rawBody === "string"
|
|
173
|
+
? rawBody
|
|
174
|
+
: await new Response(rawBody).text();
|
|
175
|
+
if (bodyText) {
|
|
176
|
+
parsedBody = JSON.parse(bodyText);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
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
|
+
const request = new Request(url.toString(), {
|
|
184
|
+
method,
|
|
185
|
+
headers,
|
|
186
|
+
});
|
|
187
|
+
|
|
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
|
+
const tenantId = headers.get("x-tenant-id");
|
|
210
|
+
if (!tenantId) {
|
|
211
|
+
return new Response("missing x-tenant-id header", { status: 401 });
|
|
212
|
+
}
|
|
213
|
+
const tenant = resolveTenant(tenantId);
|
|
214
|
+
|
|
215
|
+
const tenantCli = baseCli.clone({
|
|
216
|
+
cwd: tenant.cwd,
|
|
217
|
+
env: { ...tenant.env, TENANT_ID: tenantId },
|
|
218
|
+
fs: tenant.fs,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const mcpServer = new McpLowLevelServer(
|
|
222
|
+
{ name: "notes-app-mcp", version: "1.0.0" },
|
|
223
|
+
{ capabilities: {} },
|
|
224
|
+
);
|
|
225
|
+
addCliToolsToMcp({ cli: tenantCli, server: mcpServer });
|
|
226
|
+
|
|
227
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
228
|
+
sessionIdGenerator: () => randomUUID(),
|
|
229
|
+
// Pure request/response — no SSE streaming to clean up.
|
|
230
|
+
enableJsonResponse: true,
|
|
231
|
+
onsessioninitialized: (sid) => {
|
|
232
|
+
transports.set(sid, transport);
|
|
233
|
+
},
|
|
234
|
+
onsessionclosed: (sid) => {
|
|
235
|
+
transports.delete(sid);
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
transport.onclose = () => {
|
|
240
|
+
const sid = transport.sessionId;
|
|
241
|
+
if (sid) {
|
|
242
|
+
transports.delete(sid);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
await mcpServer.connect(transport);
|
|
247
|
+
return transport.handleRequest(request, { parsedBody });
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
return { fetch: customFetch, transports };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ─── Tests ────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () => {
|
|
256
|
+
function setupScenario() {
|
|
257
|
+
const baseCli = buildBaseCli();
|
|
258
|
+
const tenants = new Map<string, TenantState>();
|
|
259
|
+
tenants.set("tenant-a", {
|
|
260
|
+
cwd: "/workspace-a",
|
|
261
|
+
env: { ROLE: "writer" },
|
|
262
|
+
fs: new InMemoryFs(),
|
|
263
|
+
});
|
|
264
|
+
tenants.set("tenant-b", {
|
|
265
|
+
cwd: "/workspace-b",
|
|
266
|
+
env: { ROLE: "reader" },
|
|
267
|
+
fs: new InMemoryFs(),
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const { fetch: tenantFetch } = createMultiTenantFetch({
|
|
271
|
+
baseCli,
|
|
272
|
+
resolveTenant: (id) => {
|
|
273
|
+
const tenant = tenants.get(id);
|
|
274
|
+
if (!tenant) throw new Error(`unknown tenant ${id}`);
|
|
275
|
+
return tenant;
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// The URL is a placeholder — the in-process fetch never looks
|
|
280
|
+
// at the host, just the method/headers/body.
|
|
281
|
+
const endpoint = new URL("http://in-memory-mcp.test/mcp");
|
|
282
|
+
|
|
283
|
+
async function connectTenant(tenantId: string): Promise<Client> {
|
|
284
|
+
const transport = new StreamableHTTPClientTransport(endpoint, {
|
|
285
|
+
fetch: tenantFetch,
|
|
286
|
+
requestInit: {
|
|
287
|
+
headers: {
|
|
288
|
+
"x-tenant-id": tenantId,
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
const client = new Client(
|
|
293
|
+
{ name: `${tenantId}-client`, version: "1.0.0" },
|
|
294
|
+
{ capabilities: {} },
|
|
295
|
+
);
|
|
296
|
+
await client.connect(transport);
|
|
297
|
+
return client;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { tenants, connectTenant };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function firstTextBlock(result: Awaited<ReturnType<Client["callTool"]>>): string {
|
|
304
|
+
const content = (result as { content?: Array<{ type: string; text?: string }> }).content ?? [];
|
|
305
|
+
return content.find((block) => block.type === "text")?.text ?? "";
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
it("routes each session to its own cli clone with tenant-specific cwd/env/fs", async () => {
|
|
309
|
+
const { tenants, connectTenant } = setupScenario();
|
|
310
|
+
|
|
311
|
+
const aliceClient = await connectTenant("tenant-a");
|
|
312
|
+
const bobClient = await connectTenant("tenant-b");
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
// Each client sees the same tool catalog — it comes from the
|
|
316
|
+
// shared cli definition.
|
|
317
|
+
const aliceTools = (await aliceClient.listTools()).tools.map((t) => t.name).sort();
|
|
318
|
+
const bobTools = (await bobClient.listTools()).tools.map((t) => t.name).sort();
|
|
319
|
+
expect(aliceTools).toEqual(["load", "save"]);
|
|
320
|
+
expect(bobTools).toEqual(["load", "save"]);
|
|
321
|
+
|
|
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
|
+
const aliceSave = await aliceClient.callTool({
|
|
326
|
+
name: "save",
|
|
327
|
+
arguments: { filename: "notes.txt", content: "alice-secret" },
|
|
328
|
+
});
|
|
329
|
+
const bobSave = await bobClient.callTool({
|
|
330
|
+
name: "save",
|
|
331
|
+
arguments: { filename: "notes.txt", content: "bob-secret" },
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(firstTextBlock(aliceSave)).toContain("/workspace-a/notes.txt");
|
|
335
|
+
expect(firstTextBlock(aliceSave)).toContain("tenant-a");
|
|
336
|
+
expect(firstTextBlock(bobSave)).toContain("/workspace-b/notes.txt");
|
|
337
|
+
expect(firstTextBlock(bobSave)).toContain("tenant-b");
|
|
338
|
+
|
|
339
|
+
// Each tenant reads back what it wrote.
|
|
340
|
+
const aliceLoad = await aliceClient.callTool({
|
|
341
|
+
name: "load",
|
|
342
|
+
arguments: { filename: "notes.txt" },
|
|
343
|
+
});
|
|
344
|
+
const bobLoad = await bobClient.callTool({
|
|
345
|
+
name: "load",
|
|
346
|
+
arguments: { filename: "notes.txt" },
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
expect(firstTextBlock(aliceLoad)).toContain("alice-secret");
|
|
350
|
+
expect(firstTextBlock(aliceLoad)).not.toContain("bob-secret");
|
|
351
|
+
expect(firstTextBlock(bobLoad)).toContain("bob-secret");
|
|
352
|
+
expect(firstTextBlock(bobLoad)).not.toContain("alice-secret");
|
|
353
|
+
|
|
354
|
+
// Sanity check: the underlying in-memory maps really are
|
|
355
|
+
// disjoint. Tenant A's fs only has tenant A's file.
|
|
356
|
+
const tenantAFs = tenants.get("tenant-a")!.fs;
|
|
357
|
+
const tenantBFs = tenants.get("tenant-b")!.fs;
|
|
358
|
+
expect([...tenantAFs.files.keys()]).toEqual(["/workspace-a/notes.txt"]);
|
|
359
|
+
expect([...tenantBFs.files.keys()]).toEqual(["/workspace-b/notes.txt"]);
|
|
360
|
+
expect(tenantAFs.files.get("/workspace-a/notes.txt")).toBe("alice-secret");
|
|
361
|
+
expect(tenantBFs.files.get("/workspace-b/notes.txt")).toBe("bob-secret");
|
|
362
|
+
} finally {
|
|
363
|
+
await aliceClient.close();
|
|
364
|
+
await bobClient.close();
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("raises a tool error when a tenant reads a file it never wrote", async () => {
|
|
369
|
+
const { connectTenant } = setupScenario();
|
|
370
|
+
|
|
371
|
+
const bobClient = await connectTenant("tenant-b");
|
|
372
|
+
try {
|
|
373
|
+
const result = await bobClient.callTool({
|
|
374
|
+
name: "load",
|
|
375
|
+
arguments: { filename: "does-not-exist.txt" },
|
|
376
|
+
});
|
|
377
|
+
expect(result.isError).toBe(true);
|
|
378
|
+
expect(firstTextBlock(result)).toMatch(/ENOENT/);
|
|
379
|
+
} finally {
|
|
380
|
+
await bobClient.close();
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
});
|
package/src/cli-to-mcp.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
9
|
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
10
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
10
11
|
import {
|
|
11
12
|
CallToolRequestSchema,
|
|
12
13
|
ErrorCode,
|
|
@@ -15,7 +16,16 @@ import {
|
|
|
15
16
|
type CallToolResult,
|
|
16
17
|
type Tool,
|
|
17
18
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
18
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
coerceBySchema,
|
|
21
|
+
extractJsonSchema,
|
|
22
|
+
GokeProcessExit,
|
|
23
|
+
type Command,
|
|
24
|
+
type Goke,
|
|
25
|
+
type GokeExecutionContext,
|
|
26
|
+
type GokeOutputStream,
|
|
27
|
+
type StandardJSONSchemaV1,
|
|
28
|
+
} from "goke";
|
|
19
29
|
|
|
20
30
|
const CLI_TO_MCP_STATE = Symbol.for("@goke/mcp/cli-to-mcp-state");
|
|
21
31
|
|
|
@@ -43,11 +53,43 @@ interface OptionBinding {
|
|
|
43
53
|
interface CliToolBinding {
|
|
44
54
|
tool: Tool;
|
|
45
55
|
command: Command;
|
|
56
|
+
/**
|
|
57
|
+
* The goke cli that owns this command. Used at tool-call time to
|
|
58
|
+
* build a `GokeExecutionContext` (console/fs/process) via
|
|
59
|
+
* `cli.createExecutionContext(override)` so actions receive the same
|
|
60
|
+
* injected context they would when invoked from the command line.
|
|
61
|
+
*/
|
|
62
|
+
cli: Goke;
|
|
46
63
|
positionalArgs: CommandArgLike[];
|
|
47
64
|
options: OptionBinding[];
|
|
48
65
|
requiredNames: string[];
|
|
49
66
|
}
|
|
50
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A `GokeOutputStream` that accumulates writes into a string.
|
|
70
|
+
*
|
|
71
|
+
* Used to capture what an action writes through `ctx.console.log` /
|
|
72
|
+
* `ctx.console.error` / `ctx.process.stdout` / `ctx.process.stderr`
|
|
73
|
+
* so it can be surfaced in the MCP `CallToolResult.content` instead of
|
|
74
|
+
* leaking into the host process stdout (which, for the stdio MCP
|
|
75
|
+
* transport, is the JSON-RPC channel itself).
|
|
76
|
+
*/
|
|
77
|
+
interface TextCaptureStream extends GokeOutputStream {
|
|
78
|
+
readonly text: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function createTextCaptureStream(): TextCaptureStream {
|
|
82
|
+
const chunks: string[] = [];
|
|
83
|
+
return {
|
|
84
|
+
get text() {
|
|
85
|
+
return chunks.join("");
|
|
86
|
+
},
|
|
87
|
+
write(data: string) {
|
|
88
|
+
chunks.push(data);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
51
93
|
interface CliToMcpState {
|
|
52
94
|
toolsByName: Map<string, CliToolBinding>;
|
|
53
95
|
commandToToolName: Map<string, string>;
|
|
@@ -253,6 +295,107 @@ function isToolNotFoundResult(result: unknown, toolName: string): boolean {
|
|
|
253
295
|
return text.includes("tool") && text.includes("not found") && text.includes(toolName.toLowerCase());
|
|
254
296
|
}
|
|
255
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Build the same `GokeExecutionContext` an action would receive from
|
|
300
|
+
* `cli.parse()`, but with capture streams for stdout/stderr and an
|
|
301
|
+
* `exit` that throws `GokeProcessExit` instead of killing the host
|
|
302
|
+
* process.
|
|
303
|
+
*
|
|
304
|
+
* Capturing is required for the stdio MCP transport because the host
|
|
305
|
+
* `process.stdout` is the JSON-RPC channel — any write to it would
|
|
306
|
+
* corrupt the protocol. Capturing is also what lets us surface
|
|
307
|
+
* `ctx.console.log` output in the `CallToolResult.content`.
|
|
308
|
+
*/
|
|
309
|
+
function createCallToolExecutionContext(cli: Goke): {
|
|
310
|
+
ctx: GokeExecutionContext;
|
|
311
|
+
stdout: TextCaptureStream;
|
|
312
|
+
stderr: TextCaptureStream;
|
|
313
|
+
} {
|
|
314
|
+
const stdout = createTextCaptureStream();
|
|
315
|
+
const stderr = createTextCaptureStream();
|
|
316
|
+
const ctx = cli.createExecutionContext({
|
|
317
|
+
stdout,
|
|
318
|
+
stderr,
|
|
319
|
+
// Swallow the user-level exit: the outer createExecutionContext
|
|
320
|
+
// wrapper will still throw `GokeProcessExit` after this returns,
|
|
321
|
+
// which `runCliTool` catches and turns into a `CallToolResult`.
|
|
322
|
+
exit: () => {},
|
|
323
|
+
});
|
|
324
|
+
return { ctx, stdout, stderr };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Build a `CallToolResult` from an action's return value plus any
|
|
329
|
+
* text that was captured from the injected `ctx.console` /
|
|
330
|
+
* `ctx.process.stdout` / `ctx.process.stderr` streams.
|
|
331
|
+
*
|
|
332
|
+
* Precedence rules:
|
|
333
|
+
* 1. If the action returned a ready-made `CallToolResult` (object
|
|
334
|
+
* with a `content` key), honor it as-is. Captured output is
|
|
335
|
+
* ignored to give authors a fully manual escape hatch.
|
|
336
|
+
* 2. If anything was captured on stdout or stderr, emit one text
|
|
337
|
+
* block per non-empty stream (stdout first, then stderr) and
|
|
338
|
+
* append the stringified return value as a trailing block when
|
|
339
|
+
* it is non-empty. This keeps warnings written via
|
|
340
|
+
* `ctx.console.error` / `ctx.process.stderr.write` from being
|
|
341
|
+
* silently dropped when the action also returns a value.
|
|
342
|
+
* 3. Otherwise fall back to the legacy behavior (stringify the
|
|
343
|
+
* return value, empty string when `undefined`).
|
|
344
|
+
*/
|
|
345
|
+
function buildCallToolResult(
|
|
346
|
+
returnValue: unknown,
|
|
347
|
+
capturedStdout: string,
|
|
348
|
+
capturedStderr: string,
|
|
349
|
+
): CallToolResult {
|
|
350
|
+
if (returnValue && typeof returnValue === "object" && "content" in returnValue) {
|
|
351
|
+
return returnValue as CallToolResult;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (capturedStdout || capturedStderr) {
|
|
355
|
+
const blocks: Array<{ type: "text"; text: string }> = [];
|
|
356
|
+
if (capturedStdout) {
|
|
357
|
+
blocks.push({ type: "text", text: capturedStdout });
|
|
358
|
+
}
|
|
359
|
+
if (capturedStderr) {
|
|
360
|
+
blocks.push({ type: "text", text: capturedStderr });
|
|
361
|
+
}
|
|
362
|
+
const valueText = formatTextResult(returnValue);
|
|
363
|
+
if (valueText) {
|
|
364
|
+
blocks.push({ type: "text", text: valueText });
|
|
365
|
+
}
|
|
366
|
+
return { content: blocks };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return toCallToolResult(returnValue);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Build an error `CallToolResult` from captured output + the process
|
|
374
|
+
* exit code thrown by `ctx.process.exit(code)`. Mirrors the
|
|
375
|
+
* `{ stdout, stderr, exitCode }` shape just-bash produces, but in the
|
|
376
|
+
* MCP content-block format.
|
|
377
|
+
*/
|
|
378
|
+
function buildProcessExitResult(
|
|
379
|
+
exitCode: number,
|
|
380
|
+
capturedStdout: string,
|
|
381
|
+
capturedStderr: string,
|
|
382
|
+
): CallToolResult {
|
|
383
|
+
const content: Array<{ type: "text"; text: string }> = [];
|
|
384
|
+
if (capturedStdout) {
|
|
385
|
+
content.push({ type: "text", text: capturedStdout });
|
|
386
|
+
}
|
|
387
|
+
if (capturedStderr) {
|
|
388
|
+
content.push({ type: "text", text: capturedStderr });
|
|
389
|
+
}
|
|
390
|
+
if (content.length === 0) {
|
|
391
|
+
content.push({ type: "text", text: `Process exited with code ${exitCode}` });
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
isError: exitCode !== 0,
|
|
395
|
+
content,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
256
399
|
async function runCliTool(binding: CliToolBinding, argumentsObject: Record<string, unknown>): Promise<CallToolResult> {
|
|
257
400
|
for (const requiredName of binding.requiredNames) {
|
|
258
401
|
if (getToolCallArguments(argumentsObject, requiredName) === undefined) {
|
|
@@ -302,19 +445,38 @@ async function runCliTool(binding: CliToolBinding, argumentsObject: Record<strin
|
|
|
302
445
|
throw new McpError(ErrorCode.InvalidParams, `Command ${binding.command.name} has no action`);
|
|
303
446
|
}
|
|
304
447
|
|
|
448
|
+
// Build the same execution context an action would see when invoked
|
|
449
|
+
// from the command line, but with capture streams + a no-op `exit`
|
|
450
|
+
// so tool calls can't corrupt the MCP transport or kill the host.
|
|
451
|
+
const { ctx, stdout, stderr } = createCallToolExecutionContext(binding.cli);
|
|
452
|
+
|
|
305
453
|
try {
|
|
306
|
-
|
|
307
|
-
|
|
454
|
+
// Match `Goke#runMatchedCommand` by calling the action with the
|
|
455
|
+
// owning cli as `this`. Keeps behavior parity for JS authors who
|
|
456
|
+
// reference `this.name` / `this.options` from inside an action.
|
|
457
|
+
const result = await Promise.resolve(
|
|
458
|
+
action.apply(binding.cli, [...positionalValues, optionsObject, ctx]),
|
|
459
|
+
);
|
|
460
|
+
return buildCallToolResult(result, stdout.text, stderr.text);
|
|
308
461
|
} catch (error) {
|
|
462
|
+
if (error instanceof GokeProcessExit) {
|
|
463
|
+
return buildProcessExitResult(error.code, stdout.text, stderr.text);
|
|
464
|
+
}
|
|
309
465
|
const message = error instanceof Error ? error.message : String(error);
|
|
466
|
+
const content: Array<{ type: "text"; text: string }> = [
|
|
467
|
+
{ type: "text", text: message },
|
|
468
|
+
];
|
|
469
|
+
if (stderr.text) {
|
|
470
|
+
content.push({ type: "text", text: stderr.text });
|
|
471
|
+
}
|
|
310
472
|
return {
|
|
311
473
|
isError: true,
|
|
312
|
-
content
|
|
474
|
+
content,
|
|
313
475
|
};
|
|
314
476
|
}
|
|
315
477
|
}
|
|
316
478
|
|
|
317
|
-
function createBinding(command: Command, toolName: string): CliToolBinding {
|
|
479
|
+
function createBinding(cli: Goke, command: Command, toolName: string): CliToolBinding {
|
|
318
480
|
const positionalArgs = command.args as unknown as CommandArgLike[];
|
|
319
481
|
const options = command.options as unknown as OptionLike[];
|
|
320
482
|
|
|
@@ -369,6 +531,7 @@ function createBinding(command: Command, toolName: string): CliToolBinding {
|
|
|
369
531
|
inputSchema,
|
|
370
532
|
},
|
|
371
533
|
command,
|
|
534
|
+
cli,
|
|
372
535
|
positionalArgs,
|
|
373
536
|
options: optionBindings,
|
|
374
537
|
requiredNames: Array.from(new Set(requiredNames)),
|
|
@@ -456,6 +619,73 @@ function getOrInstallState(server: Server): CliToMcpState {
|
|
|
456
619
|
return state;
|
|
457
620
|
}
|
|
458
621
|
|
|
622
|
+
export interface CreateMcpActionOptions {
|
|
623
|
+
/** The CLI instance whose commands will be exposed as MCP tools */
|
|
624
|
+
cli: Goke;
|
|
625
|
+
/** Additional filter for which commands to expose. The MCP command itself is always excluded. */
|
|
626
|
+
commandFilter?: (commandName: string) => boolean;
|
|
627
|
+
/** Custom tool name sanitizer */
|
|
628
|
+
sanitizeToolName?: (commandName: string) => string;
|
|
629
|
+
/** MCP server name. Defaults to the CLI name or 'cli-mcp-server' */
|
|
630
|
+
serverName?: string;
|
|
631
|
+
/** MCP server version. Defaults to '1.0.0' */
|
|
632
|
+
serverVersion?: string;
|
|
633
|
+
/** Custom transport factory. Defaults to StdioServerTransport (stdin/stdout). */
|
|
634
|
+
createTransport?: () => Transport | Promise<Transport>;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Create a goke action callback that starts an MCP server over stdio.
|
|
639
|
+
*
|
|
640
|
+
* Exposes all CLI commands as MCP tools, automatically excluding the
|
|
641
|
+
* command this action is attached to.
|
|
642
|
+
*
|
|
643
|
+
* @example
|
|
644
|
+
* ```ts
|
|
645
|
+
* cli.command('mcp', 'Start MCP server over stdio')
|
|
646
|
+
* .action(createMcpAction({ cli }))
|
|
647
|
+
* ```
|
|
648
|
+
*/
|
|
649
|
+
export function createMcpAction(options: CreateMcpActionOptions): (...args: any[]) => Promise<void> {
|
|
650
|
+
const { cli, commandFilter: userFilter, sanitizeToolName, serverName, serverVersion, createTransport } = options;
|
|
651
|
+
|
|
652
|
+
return async () => {
|
|
653
|
+
// At call time, goke has already matched the command and set matchedCommandName.
|
|
654
|
+
// We use it to auto-exclude the MCP command itself from the tool list.
|
|
655
|
+
const mcpCommandName = cli.matchedCommandName;
|
|
656
|
+
|
|
657
|
+
const { Server: ServerClass } = await import("@modelcontextprotocol/sdk/server/index.js");
|
|
658
|
+
|
|
659
|
+
const server = new ServerClass(
|
|
660
|
+
{
|
|
661
|
+
name: serverName || cli.name || "cli-mcp-server",
|
|
662
|
+
version: serverVersion || "1.0.0",
|
|
663
|
+
},
|
|
664
|
+
{ capabilities: {} },
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
addCliToolsToMcp({
|
|
668
|
+
cli,
|
|
669
|
+
server,
|
|
670
|
+
commandFilter: (name) => {
|
|
671
|
+
if (mcpCommandName && name === mcpCommandName) return false;
|
|
672
|
+
return userFilter ? userFilter(name) : true;
|
|
673
|
+
},
|
|
674
|
+
sanitizeToolName,
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
let transport: Transport;
|
|
678
|
+
if (createTransport) {
|
|
679
|
+
transport = await createTransport();
|
|
680
|
+
} else {
|
|
681
|
+
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
682
|
+
transport = new StdioServerTransport();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
await server.connect(transport);
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
459
689
|
export function addCliToolsToMcp(options: AddCliToolsToMcpOptions): void {
|
|
460
690
|
const { cli, commandFilter, sanitizeToolName = defaultSanitizeToolName } = options;
|
|
461
691
|
const server = resolveServer(options.server);
|
|
@@ -493,7 +723,7 @@ export function addCliToolsToMcp(options: AddCliToolsToMcpOptions): void {
|
|
|
493
723
|
const toolName = uniqueToolName(baseToolName, usedNames);
|
|
494
724
|
usedNames.add(toolName);
|
|
495
725
|
|
|
496
|
-
const binding = createBinding(command, toolName);
|
|
726
|
+
const binding = createBinding(cli, command, toolName);
|
|
497
727
|
state.toolsByName.set(toolName, binding);
|
|
498
728
|
state.commandToToolName.set(command.name, toolName);
|
|
499
729
|
}
|