@goke/mcp 0.0.9 → 0.0.11

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,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
+ });
package/dist/auth.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { OAuthFlowResult, StartOAuthFlowOptions } from "./types.js";
2
2
  /**
3
3
  * Start the OAuth flow for an MCP server.
4
- * This is an internal function - consumers should not call this directly.
5
- * It is automatically triggered by addMcpCommands when a 401 error occurs.
4
+ *
5
+ * Used internally by addMcpCommands on 401 errors, but also available
6
+ * for CLIs that need explicit control over the auth flow (e.g. a login
7
+ * command that runs the flow in a background daemon).
6
8
  *
7
9
  * This function:
8
10
  * 1. Starts a local callback server on a random port
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAiB,eAAe,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AA0BxF;;;;;;;;;;;;GAYG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC,CA4E7F;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAazD"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAiB,eAAe,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AA0BxF;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC,CA4E7F;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAazD"}
package/dist/auth.js CHANGED
@@ -24,8 +24,10 @@ async function openBrowser(url) {
24
24
  }
25
25
  /**
26
26
  * Start the OAuth flow for an MCP server.
27
- * This is an internal function - consumers should not call this directly.
28
- * It is automatically triggered by addMcpCommands when a 401 error occurs.
27
+ *
28
+ * Used internally by addMcpCommands on 401 errors, but also available
29
+ * for CLIs that need explicit control over the auth flow (e.g. a login
30
+ * command that runs the flow in a background daemon).
29
31
  *
30
32
  * This function:
31
33
  * 1. Starts a local callback server on a random port
@@ -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;AACxE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+CAA+C,CAAC;AAS/E,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,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"}
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"}
@@ -5,8 +5,19 @@
5
5
  * or a high-level McpServer by mounting tools/list + tools/call handlers.
6
6
  */
7
7
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from "@modelcontextprotocol/sdk/types.js";
8
- import { coerceBySchema, extractJsonSchema } from "goke";
8
+ import { coerceBySchema, extractJsonSchema, GokeProcessExit, } from "goke";
9
9
  const CLI_TO_MCP_STATE = Symbol.for("@goke/mcp/cli-to-mcp-state");
10
+ function createTextCaptureStream() {
11
+ const chunks = [];
12
+ return {
13
+ get text() {
14
+ return chunks.join("");
15
+ },
16
+ write(data) {
17
+ chunks.push(data);
18
+ },
19
+ };
20
+ }
10
21
  function isMountableCommand(command, commandFilter) {
11
22
  if (!command.commandAction) {
12
23
  return false;
@@ -167,6 +178,90 @@ function isToolNotFoundResult(result, toolName) {
167
178
  const text = String(textBlock?.text ?? "").toLowerCase();
168
179
  return text.includes("tool") && text.includes("not found") && text.includes(toolName.toLowerCase());
169
180
  }
181
+ /**
182
+ * Build the same `GokeExecutionContext` an action would receive from
183
+ * `cli.parse()`, but with capture streams for stdout/stderr and an
184
+ * `exit` that throws `GokeProcessExit` instead of killing the host
185
+ * process.
186
+ *
187
+ * Capturing is required for the stdio MCP transport because the host
188
+ * `process.stdout` is the JSON-RPC channel — any write to it would
189
+ * corrupt the protocol. Capturing is also what lets us surface
190
+ * `ctx.console.log` output in the `CallToolResult.content`.
191
+ */
192
+ function createCallToolExecutionContext(cli) {
193
+ const stdout = createTextCaptureStream();
194
+ const stderr = createTextCaptureStream();
195
+ const ctx = cli.createExecutionContext({
196
+ stdout,
197
+ stderr,
198
+ // Swallow the user-level exit: the outer createExecutionContext
199
+ // wrapper will still throw `GokeProcessExit` after this returns,
200
+ // which `runCliTool` catches and turns into a `CallToolResult`.
201
+ exit: () => { },
202
+ });
203
+ return { ctx, stdout, stderr };
204
+ }
205
+ /**
206
+ * Build a `CallToolResult` from an action's return value plus any
207
+ * text that was captured from the injected `ctx.console` /
208
+ * `ctx.process.stdout` / `ctx.process.stderr` streams.
209
+ *
210
+ * Precedence rules:
211
+ * 1. If the action returned a ready-made `CallToolResult` (object
212
+ * with a `content` key), honor it as-is. Captured output is
213
+ * ignored to give authors a fully manual escape hatch.
214
+ * 2. If anything was captured on stdout or stderr, emit one text
215
+ * block per non-empty stream (stdout first, then stderr) and
216
+ * append the stringified return value as a trailing block when
217
+ * it is non-empty. This keeps warnings written via
218
+ * `ctx.console.error` / `ctx.process.stderr.write` from being
219
+ * silently dropped when the action also returns a value.
220
+ * 3. Otherwise fall back to the legacy behavior (stringify the
221
+ * return value, empty string when `undefined`).
222
+ */
223
+ function buildCallToolResult(returnValue, capturedStdout, capturedStderr) {
224
+ if (returnValue && typeof returnValue === "object" && "content" in returnValue) {
225
+ return returnValue;
226
+ }
227
+ if (capturedStdout || capturedStderr) {
228
+ const blocks = [];
229
+ if (capturedStdout) {
230
+ blocks.push({ type: "text", text: capturedStdout });
231
+ }
232
+ if (capturedStderr) {
233
+ blocks.push({ type: "text", text: capturedStderr });
234
+ }
235
+ const valueText = formatTextResult(returnValue);
236
+ if (valueText) {
237
+ blocks.push({ type: "text", text: valueText });
238
+ }
239
+ return { content: blocks };
240
+ }
241
+ return toCallToolResult(returnValue);
242
+ }
243
+ /**
244
+ * Build an error `CallToolResult` from captured output + the process
245
+ * exit code thrown by `ctx.process.exit(code)`. Mirrors the
246
+ * `{ stdout, stderr, exitCode }` shape just-bash produces, but in the
247
+ * MCP content-block format.
248
+ */
249
+ function buildProcessExitResult(exitCode, capturedStdout, capturedStderr) {
250
+ const content = [];
251
+ if (capturedStdout) {
252
+ content.push({ type: "text", text: capturedStdout });
253
+ }
254
+ if (capturedStderr) {
255
+ content.push({ type: "text", text: capturedStderr });
256
+ }
257
+ if (content.length === 0) {
258
+ content.push({ type: "text", text: `Process exited with code ${exitCode}` });
259
+ }
260
+ return {
261
+ isError: exitCode !== 0,
262
+ content,
263
+ };
264
+ }
170
265
  async function runCliTool(binding, argumentsObject) {
171
266
  for (const requiredName of binding.requiredNames) {
172
267
  if (getToolCallArguments(argumentsObject, requiredName) === undefined) {
@@ -212,19 +307,35 @@ async function runCliTool(binding, argumentsObject) {
212
307
  if (!action) {
213
308
  throw new McpError(ErrorCode.InvalidParams, `Command ${binding.command.name} has no action`);
214
309
  }
310
+ // Build the same execution context an action would see when invoked
311
+ // from the command line, but with capture streams + a no-op `exit`
312
+ // so tool calls can't corrupt the MCP transport or kill the host.
313
+ const { ctx, stdout, stderr } = createCallToolExecutionContext(binding.cli);
215
314
  try {
216
- const result = await Promise.resolve(action(...positionalValues, optionsObject));
217
- return toCallToolResult(result);
315
+ // Match `Goke#runMatchedCommand` by calling the action with the
316
+ // owning cli as `this`. Keeps behavior parity for JS authors who
317
+ // reference `this.name` / `this.options` from inside an action.
318
+ const result = await Promise.resolve(action.apply(binding.cli, [...positionalValues, optionsObject, ctx]));
319
+ return buildCallToolResult(result, stdout.text, stderr.text);
218
320
  }
219
321
  catch (error) {
322
+ if (error instanceof GokeProcessExit) {
323
+ return buildProcessExitResult(error.code, stdout.text, stderr.text);
324
+ }
220
325
  const message = error instanceof Error ? error.message : String(error);
326
+ const content = [
327
+ { type: "text", text: message },
328
+ ];
329
+ if (stderr.text) {
330
+ content.push({ type: "text", text: stderr.text });
331
+ }
221
332
  return {
222
333
  isError: true,
223
- content: [{ type: "text", text: message }],
334
+ content,
224
335
  };
225
336
  }
226
337
  }
227
- function createBinding(command, toolName) {
338
+ function createBinding(cli, command, toolName) {
228
339
  const positionalArgs = command.args;
229
340
  const options = command.options;
230
341
  const properties = {};
@@ -272,6 +383,7 @@ function createBinding(command, toolName) {
272
383
  inputSchema,
273
384
  },
274
385
  command,
386
+ cli,
275
387
  positionalArgs,
276
388
  options: optionBindings,
277
389
  requiredNames: Array.from(new Set(requiredNames)),
@@ -417,7 +529,7 @@ export function addCliToolsToMcp(options) {
417
529
  const baseToolName = defaultSanitizeToolName(sanitizeToolName(command.name));
418
530
  const toolName = uniqueToolName(baseToolName, usedNames);
419
531
  usedNames.add(toolName);
420
- const binding = createBinding(command, toolName);
532
+ const binding = createBinding(cli, command, toolName);
421
533
  state.toolsByName.set(toolName, binding);
422
534
  state.commandToToolName.set(command.name, toolName);
423
535
  }
package/dist/index.d.ts CHANGED
@@ -41,11 +41,12 @@
41
41
  */
42
42
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
43
43
  import type { Goke } from "goke";
44
+ export { startOAuthFlow } from "./auth.js";
44
45
  import type { McpOAuthConfig } from "./types.js";
45
46
  export { addCliToolsToMcp, createMcpAction } from "./cli-to-mcp.js";
46
47
  export type { AddCliToolsToMcpOptions, CreateMcpActionOptions } from "./cli-to-mcp.js";
47
48
  export type { Transport };
48
- export type { McpOAuthConfig, McpOAuthState } from "./types.js";
49
+ export type { McpOAuthConfig, McpOAuthState, StartOAuthFlowOptions, OAuthFlowResult } from "./types.js";
49
50
  export interface CachedMcpTools {
50
51
  tools: Array<{
51
52
  name: string;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+CAA+C,CAAC;AAC/E,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAKjC,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,YAAY,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACpE,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAC;AAGvF,YAAY,EAAE,SAAS,EAAE,CAAC;AAC1B,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhE,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,KAAK,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,WAAW,CAAC,EAAE,OAAO,CAAC;KACvB,CAAC,CAAC;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAID,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,IAAI,CAAC;IACV;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAErC;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,SAAS,CAAC,EAAE,MAAM,KAAK,SAAS,GAAG,IAAI,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC;IAEvF;;;;;;;;;;;;;;OAcG;IACH,KAAK,CAAC,EAAE,cAAc,CAAC;IAEvB;;OAEG;IACH,SAAS,EAAE,MAAM,cAAc,GAAG,SAAS,CAAC;IAE5C;;OAEG;IACH,SAAS,EAAE,CAAC,KAAK,EAAE,cAAc,GAAG,SAAS,KAAK,IAAI,CAAC;CACxD;AAmID;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyMlF"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+CAA+C,CAAC;AAC/E,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAKjC,OAAO,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,YAAY,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACpE,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAC;AAGvF,YAAY,EAAE,SAAS,EAAE,CAAC;AAC1B,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAExG,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,KAAK,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,WAAW,CAAC,EAAE,OAAO,CAAC;KACvB,CAAC,CAAC;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAID,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,IAAI,CAAC;IACV;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAErC;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,SAAS,CAAC,EAAE,MAAM,KAAK,SAAS,GAAG,IAAI,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC;IAEvF;;;;;;;;;;;;;;OAcG;IACH,KAAK,CAAC,EAAE,cAAc,CAAC;IAEvB;;OAEG;IACH,SAAS,EAAE,MAAM,cAAc,GAAG,SAAS,CAAC;IAE5C;;OAEG;IACH,SAAS,EAAE,CAAC,KAAK,EAAE,cAAc,GAAG,SAAS,KAAK,IAAI,CAAC;CACxD;AAmID;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyMlF"}
package/dist/index.js CHANGED
@@ -45,6 +45,7 @@ import { wrapJsonSchema } from "goke";
45
45
  import yaml from "js-yaml";
46
46
  import { FileOAuthProvider } from "./oauth-provider.js";
47
47
  import { startOAuthFlow, isAuthRequiredError } from "./auth.js";
48
+ export { startOAuthFlow } from "./auth.js";
48
49
  export { addCliToolsToMcp, createMcpAction } from "./cli-to-mcp.js";
49
50
  const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
50
51
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goke/mcp",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "type": "module",
5
5
  "description": "Dynamically generate CLI commands from MCP server tools",
6
6
  "repository": {
@@ -51,7 +51,7 @@
51
51
  "@types/node": "^22.19.7",
52
52
  "vitest": "^3.1.0",
53
53
  "zod": "^4.3.6",
54
- "goke": "^6.3.0"
54
+ "goke": "^6.13.0"
55
55
  },
56
56
  "scripts": {
57
57
  "clean": "rm -rf dist",