@goke/mcp 0.0.13 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Multi-tenant remote-MCP test.
3
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.
4
+ * Proves that one goke cli exposed over stateless MCP streamable HTTP
5
+ * can serve multiple concurrent users with fully isolated state
6
+ * (in-memory fs + cwd + env) — no shared host process stdio, no
7
+ * cross-tenant leaks, no mcp-session-id map. Isolation comes from
8
+ * the `x-tenant-id` header on each POST, not from `Mcp-Session-Id`.
8
9
  *
9
10
  * Wiring choices worth calling out:
10
11
  *
@@ -14,23 +15,25 @@
14
15
  * transport's `fetch` hook without ever binding a TCP socket
15
16
  * or spinning up `node:http` / Express. Same wire protocol,
16
17
  * zero sockets.
17
- * - `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
18
+ * - `sessionIdGenerator: undefined` is stateless mode. The
19
+ * transport does not emit or expect `mcp-session-id`. Each
20
+ * POST builds a fresh Server + transport, then closes them.
21
+ * - `enableJsonResponse: true` switches the transport off SSE
22
+ * and into pure request/response JSON. GET SSE opens are
23
+ * answered with `405`, which the client treats as "server
24
+ * does not offer SSE" and moves on (see `_startOrAuthSse`
25
+ * in the SDK client).
26
+ * - Each request gets its own cli **clone** via
22
27
  * `baseCli.clone({ cwd, env, fs })`. The clone inherits the
23
28
  * command tree but owns its own `{ cwd, env, fs }`, which is
24
29
  * what `runCliTool` forwards into every action through
25
30
  * `ctx.process.*` / `ctx.fs`.
26
31
  */
27
- import { randomUUID } from "node:crypto";
28
32
  import path from "node:path";
29
33
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
30
34
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
31
35
  import { Server as McpLowLevelServer } from "@modelcontextprotocol/sdk/server/index.js";
32
36
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
33
- import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
34
37
  import { goke } from "goke";
35
38
  import { describe, expect, it } from "vitest";
36
39
  import { z } from "zod";
@@ -80,7 +83,7 @@ class InMemoryFs {
80
83
  * One cli definition, reused across tenants. Commands read / write
81
84
  * through `ctx.fs` and resolve paths against `ctx.process.cwd`, so
82
85
  * the *same* code runs per tenant but talks to a tenant-specific
83
- * filesystem when invoked via the session-scoped clone below.
86
+ * filesystem when invoked via the per-request clone below.
84
87
  */
85
88
  function buildBaseCli() {
86
89
  const cli = goke("notes-app");
@@ -102,32 +105,30 @@ function buildBaseCli() {
102
105
  return cli;
103
106
  }
104
107
  /**
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.
108
+ * Build a `FetchLike` that handles each MCP POST with a fresh
109
+ * `WebStandardStreamableHTTPServerTransport` and cli clone.
110
+ * Tenant identity comes from `x-tenant-id` on every request.
111
+ * Nothing is keyed by `mcp-session-id`.
113
112
  */
114
113
  function createMultiTenantFetch(options) {
115
114
  const { baseCli, resolveTenant } = options;
116
- const transports = new Map();
115
+ const sessionHeaders = [];
117
116
  const customFetch = async (url, init) => {
118
117
  const method = (init?.method ?? "GET").toUpperCase();
119
118
  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 });
119
+ const sessionHeader = headers.get("mcp-session-id");
120
+ if (sessionHeader) {
121
+ sessionHeaders.push(sessionHeader);
122
+ }
123
+ const origin = headers.get("origin");
124
+ if (origin && origin !== "http://in-memory-mcp.test") {
125
+ return new Response(null, { status: 403 });
126
+ }
127
+ if (method !== "POST") {
128
+ return new Response(null, { status: 405, headers: { Allow: "POST" } });
125
129
  }
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
130
  let parsedBody = undefined;
130
- if (method === "POST" && init?.body != null) {
131
+ if (init?.body != null) {
131
132
  const rawBody = init.body;
132
133
  const bodyText = typeof rawBody === "string"
133
134
  ? rawBody
@@ -136,28 +137,10 @@ function createMultiTenantFetch(options) {
136
137
  parsedBody = JSON.parse(bodyText);
137
138
  }
138
139
  }
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
140
  const request = new Request(url.toString(), {
143
141
  method,
144
142
  headers,
145
143
  });
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
144
  const tenantId = headers.get("x-tenant-id");
162
145
  if (!tenantId) {
163
146
  return new Response("missing x-tenant-id header", { status: 401 });
@@ -171,26 +154,19 @@ function createMultiTenantFetch(options) {
171
154
  const mcpServer = new McpLowLevelServer({ name: "notes-app-mcp", version: "1.0.0" }, { capabilities: {} });
172
155
  addCliToolsToMcp({ cli: tenantCli, server: mcpServer });
173
156
  const transport = new WebStandardStreamableHTTPServerTransport({
174
- sessionIdGenerator: () => randomUUID(),
175
- // Pure request/response — no SSE streaming to clean up.
157
+ sessionIdGenerator: undefined,
176
158
  enableJsonResponse: true,
177
- onsessioninitialized: (sid) => {
178
- transports.set(sid, transport);
179
- },
180
- onsessionclosed: (sid) => {
181
- transports.delete(sid);
182
- },
183
159
  });
184
- transport.onclose = () => {
185
- const sid = transport.sessionId;
186
- if (sid) {
187
- transports.delete(sid);
188
- }
189
- };
190
160
  await mcpServer.connect(transport);
191
- return transport.handleRequest(request, { parsedBody });
161
+ try {
162
+ return await transport.handleRequest(request, { parsedBody });
163
+ }
164
+ finally {
165
+ await transport.close();
166
+ await mcpServer.close();
167
+ }
192
168
  };
193
- return { fetch: customFetch, transports };
169
+ return { fetch: customFetch, sessionHeaders };
194
170
  }
195
171
  // ─── Tests ────────────────────────────────────────────────────────
196
172
  describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () => {
@@ -207,7 +183,7 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
207
183
  env: { ROLE: "reader" },
208
184
  fs: new InMemoryFs(),
209
185
  });
210
- const { fetch: tenantFetch } = createMultiTenantFetch({
186
+ const { fetch: tenantFetch, sessionHeaders } = createMultiTenantFetch({
211
187
  baseCli,
212
188
  resolveTenant: (id) => {
213
189
  const tenant = tenants.get(id);
@@ -216,8 +192,6 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
216
192
  return tenant;
217
193
  },
218
194
  });
219
- // The URL is a placeholder — the in-process fetch never looks
220
- // at the host, just the method/headers/body.
221
195
  const endpoint = new URL("http://in-memory-mcp.test/mcp");
222
196
  async function connectTenant(tenantId) {
223
197
  const transport = new StreamableHTTPClientTransport(endpoint, {
@@ -232,26 +206,21 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
232
206
  await client.connect(transport);
233
207
  return client;
234
208
  }
235
- return { tenants, connectTenant };
209
+ return { tenants, connectTenant, sessionHeaders };
236
210
  }
237
211
  function firstTextBlock(result) {
238
212
  const content = result.content ?? [];
239
213
  return content.find((block) => block.type === "text")?.text ?? "";
240
214
  }
241
- it("routes each session to its own cli clone with tenant-specific cwd/env/fs", async () => {
242
- const { tenants, connectTenant } = setupScenario();
215
+ it("routes each request to its own cli clone with tenant-specific cwd/env/fs", async () => {
216
+ const { tenants, connectTenant, sessionHeaders } = setupScenario();
243
217
  const aliceClient = await connectTenant("tenant-a");
244
218
  const bobClient = await connectTenant("tenant-b");
245
219
  try {
246
- // Each client sees the same tool catalog — it comes from the
247
- // shared cli definition.
248
220
  const aliceTools = (await aliceClient.listTools()).tools.map((t) => t.name).sort();
249
221
  const bobTools = (await bobClient.listTools()).tools.map((t) => t.name).sort();
250
222
  expect(aliceTools).toEqual(["load", "save"]);
251
223
  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
224
  const aliceSave = await aliceClient.callTool({
256
225
  name: "save",
257
226
  arguments: { filename: "notes.txt", content: "alice-secret" },
@@ -264,7 +233,6 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
264
233
  expect(firstTextBlock(aliceSave)).toContain("tenant-a");
265
234
  expect(firstTextBlock(bobSave)).toContain("/workspace-b/notes.txt");
266
235
  expect(firstTextBlock(bobSave)).toContain("tenant-b");
267
- // Each tenant reads back what it wrote.
268
236
  const aliceLoad = await aliceClient.callTool({
269
237
  name: "load",
270
238
  arguments: { filename: "notes.txt" },
@@ -277,14 +245,13 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
277
245
  expect(firstTextBlock(aliceLoad)).not.toContain("bob-secret");
278
246
  expect(firstTextBlock(bobLoad)).toContain("bob-secret");
279
247
  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
248
  const tenantAFs = tenants.get("tenant-a").fs;
283
249
  const tenantBFs = tenants.get("tenant-b").fs;
284
250
  expect([...tenantAFs.files.keys()]).toEqual(["/workspace-a/notes.txt"]);
285
251
  expect([...tenantBFs.files.keys()]).toEqual(["/workspace-b/notes.txt"]);
286
252
  expect(tenantAFs.files.get("/workspace-a/notes.txt")).toBe("alice-secret");
287
253
  expect(tenantBFs.files.get("/workspace-b/notes.txt")).toBe("bob-secret");
254
+ expect(sessionHeaders).toEqual([]);
288
255
  }
289
256
  finally {
290
257
  await aliceClient.close();
@@ -306,4 +273,22 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
306
273
  await bobClient.close();
307
274
  }
308
275
  });
276
+ it("rejects a present Origin that is not allowed", async () => {
277
+ const { fetch } = createMultiTenantFetch({
278
+ baseCli: buildBaseCli(),
279
+ resolveTenant: () => {
280
+ throw new Error("tenant must not be resolved for a forbidden origin");
281
+ },
282
+ });
283
+ const response = await fetch("http://in-memory-mcp.test/mcp", {
284
+ method: "POST",
285
+ headers: {
286
+ origin: "https://evil.example",
287
+ "x-tenant-id": "tenant-a",
288
+ "content-type": "application/json",
289
+ },
290
+ body: "{}",
291
+ });
292
+ expect(response.status).toBe(403);
293
+ });
309
294
  });
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,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"}
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,EAML,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;AA4fD,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,7 +5,7 @@
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, GokeProcessExit, } from "goke";
8
+ import { coerceBySchema, extractJsonSchema, GokeProcessExit, schemaAcceptsOmittedValue, } from "goke";
9
9
  const CLI_TO_MCP_STATE = Symbol.for("@goke/mcp/cli-to-mcp-state");
10
10
  function createTextCaptureStream() {
11
11
  const chunks = [];
@@ -359,10 +359,15 @@ function createBinding(cli, command, toolName) {
359
359
  requiredNames.push(arg.value);
360
360
  }
361
361
  }
362
+ // `--days <days>` means "value required if the flag is present". The flag
363
+ // itself is required only when a non-optional schema rejects `undefined`.
362
364
  for (const option of options) {
363
365
  const normalized = normalizeOptionSchema(option);
364
366
  properties[option.name] = normalized.schema;
365
- if (option.required) {
367
+ if (option.required
368
+ && option.default === undefined
369
+ && option.schema
370
+ && !schemaAcceptsOmittedValue(option.schema)) {
366
371
  requiredNames.push(option.name);
367
372
  }
368
373
  optionBindings.push({
package/dist/index.d.ts CHANGED
@@ -9,7 +9,6 @@
9
9
  *
10
10
  * - **Auto-discovery**: Fetches all tools from the MCP server and creates CLI commands
11
11
  * - **Caching**: Tools are cached for 1 hour to avoid reconnecting on every invocation
12
- * - **Session reuse**: MCP session IDs are cached to skip initialization handshake
13
12
  * - **Type-aware parsing**: Handles string, number, boolean, object, and array arguments
14
13
  * - **JSON schema support**: Generates CLI options from tool input schemas
15
14
  * - **OAuth support**: Automatic OAuth authentication on 401 errors (lazy auth)
@@ -41,11 +40,12 @@
41
40
  */
42
41
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
43
42
  import type { Goke } from "goke";
43
+ export { startOAuthFlow } from "./auth.js";
44
44
  import type { McpOAuthConfig } from "./types.js";
45
45
  export { addCliToolsToMcp, createMcpAction } from "./cli-to-mcp.js";
46
46
  export type { AddCliToolsToMcpOptions, CreateMcpActionOptions } from "./cli-to-mcp.js";
47
47
  export type { Transport };
48
- export type { McpOAuthConfig, McpOAuthState } from "./types.js";
48
+ export type { McpOAuthConfig, McpOAuthState, StartOAuthFlowOptions, OAuthFlowResult } from "./types.js";
49
49
  export interface CachedMcpTools {
50
50
  tools: Array<{
51
51
  name: string;
@@ -53,7 +53,6 @@ export interface CachedMcpTools {
53
53
  inputSchema?: unknown;
54
54
  }>;
55
55
  timestamp: number;
56
- sessionId?: string;
57
56
  }
58
57
  export interface AddMcpCommandsOptions {
59
58
  cli: Goke;
@@ -76,9 +75,8 @@ export interface AddMcpCommandsOptions {
76
75
  /**
77
76
  * Returns a transport to connect to the MCP server, or null if not configured.
78
77
  * Use this for stdio servers or any setup `getMcpUrl` cannot express.
79
- * @param sessionId - Optional session ID from a still-valid cache
80
78
  */
81
- getMcpTransport?: (sessionId?: string) => Transport | null | Promise<Transport | null>;
79
+ getMcpTransport?: () => Transport | null | Promise<Transport | null>;
82
80
  /**
83
81
  * Argv used to decide whether to skip live discovery.
84
82
  * Defaults to `process.argv.slice(2)`.
@@ -118,7 +116,6 @@ export interface AddMcpCommandsOptions {
118
116
  * Adds MCP tool commands to a goke CLI instance.
119
117
  *
120
118
  * Tools are cached for 1 hour to avoid connecting on every CLI invocation.
121
- * Session ID is also cached to skip MCP initialization handshake.
122
119
  *
123
120
  * OAuth is lazy - authentication only happens when a 401 error occurs.
124
121
  * After successful auth, the operation is automatically retried.
@@ -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;;;;OAIG;IACH,eAAe,CAAC,EAAE,CAAC,SAAS,CAAC,EAAE,MAAM,KAAK,SAAS,GAAG,IAAI,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC;IAEvF;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAEhB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC;IAEtD;;;;;;;;;;;;;;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;AA2JD;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CA4NlF"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;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;CACnB;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;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,SAAS,GAAG,IAAI,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC;IAErE;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAEhB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC;IAEtD;;;;;;;;;;;;;;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;AAwJD;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAkNlF"}
package/dist/index.js CHANGED
@@ -9,7 +9,6 @@
9
9
  *
10
10
  * - **Auto-discovery**: Fetches all tools from the MCP server and creates CLI commands
11
11
  * - **Caching**: Tools are cached for 1 hour to avoid reconnecting on every invocation
12
- * - **Session reuse**: MCP session IDs are cached to skip initialization handshake
13
12
  * - **Type-aware parsing**: Handles string, number, boolean, object, and array arguments
14
13
  * - **JSON schema support**: Generates CLI options from tool input schemas
15
14
  * - **OAuth support**: Automatic OAuth authentication on 401 errors (lazy auth)
@@ -45,6 +44,7 @@ import { wrapJsonSchema } from "goke";
45
44
  import yaml from "js-yaml";
46
45
  import { FileOAuthProvider } from "./oauth-provider.js";
47
46
  import { startOAuthFlow, isAuthRequiredError } from "./auth.js";
47
+ export { startOAuthFlow } from "./auth.js";
48
48
  export { addCliToolsToMcp, createMcpAction } from "./cli-to-mcp.js";
49
49
  const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
50
50
  /**
@@ -140,7 +140,7 @@ function matchesRegisteredCommand({ argv, cli }) {
140
140
  return nameParts.every((part, i) => parts[i] === part);
141
141
  });
142
142
  }
143
- function createTransportWithAuth({ url, sessionId, oauthState, oauth, headers, }) {
143
+ function createTransportWithAuth({ url, oauthState, oauth, headers, }) {
144
144
  let authProvider;
145
145
  if (oauth && oauthState?.tokens) {
146
146
  authProvider = new FileOAuthProvider({
@@ -157,7 +157,6 @@ function createTransportWithAuth({ url, sessionId, oauthState, oauth, headers, }
157
157
  }
158
158
  const hasHeaders = headers && Object.keys(headers).length > 0;
159
159
  return new StreamableHTTPClientTransport(url, {
160
- sessionId,
161
160
  authProvider,
162
161
  requestInit: hasHeaders ? { headers } : undefined,
163
162
  });
@@ -166,16 +165,13 @@ function createTransportWithAuth({ url, sessionId, oauthState, oauth, headers, }
166
165
  * Adds MCP tool commands to a goke CLI instance.
167
166
  *
168
167
  * Tools are cached for 1 hour to avoid connecting on every CLI invocation.
169
- * Session ID is also cached to skip MCP initialization handshake.
170
168
  *
171
169
  * OAuth is lazy - authentication only happens when a 401 error occurs.
172
170
  * After successful auth, the operation is automatically retried.
173
171
  */
174
172
  export async function addMcpCommands(options) {
175
173
  const { cli, commandPrefix = "", clientName = "mcp-cli-client", getMcpUrl, getMcpTransport, getHeaders, oauth, loadCache, saveCache, argv = process.argv.slice(2), } = options;
176
- // Helper to get transport - supports both old and new API
177
- const getTransport = async (sessionId) => {
178
- // New API: getMcpUrl + oauth
174
+ const getTransport = async () => {
179
175
  if (getMcpUrl) {
180
176
  const mcpUrl = getMcpUrl();
181
177
  if (!mcpUrl) {
@@ -185,15 +181,13 @@ export async function addMcpCommands(options) {
185
181
  const oauthState = oauth?.load();
186
182
  return createTransportWithAuth({
187
183
  url,
188
- sessionId,
189
184
  oauthState,
190
185
  oauth,
191
186
  headers: getHeaders?.(),
192
187
  });
193
188
  }
194
- // Custom / stdio transport
195
189
  if (getMcpTransport) {
196
- return getMcpTransport(sessionId);
190
+ return getMcpTransport();
197
191
  }
198
192
  return null;
199
193
  };
@@ -226,10 +220,8 @@ export async function addMcpCommands(options) {
226
220
  const isCacheValid = cachedTools && (Date.now() - cachedTools.timestamp) < CACHE_TTL_MS;
227
221
  const skipLiveDiscovery = isHelpOrMetaArgv(argv) || matchesRegisteredCommand({ argv, cli });
228
222
  let tools;
229
- let cachedSessionId;
230
223
  if (isCacheValid && cachedTools) {
231
224
  tools = cachedTools.tools;
232
- cachedSessionId = cachedTools.sessionId;
233
225
  }
234
226
  else if (skipLiveDiscovery) {
235
227
  if (cachedTools) {
@@ -244,7 +236,6 @@ export async function addMcpCommands(options) {
244
236
  await client.connect(transport);
245
237
  const result = await client.listTools();
246
238
  tools = result.tools;
247
- const sessionId = transport.sessionId;
248
239
  saveCache({
249
240
  tools: tools.map((t) => ({
250
241
  name: t.name,
@@ -252,9 +243,7 @@ export async function addMcpCommands(options) {
252
243
  inputSchema: t.inputSchema,
253
244
  })),
254
245
  timestamp: Date.now(),
255
- sessionId,
256
246
  });
257
- cachedSessionId = sessionId;
258
247
  }
259
248
  catch (err) {
260
249
  const shouldAuth = isAuthRequiredError(err) && oauth && getMcpUrl && !skipLiveDiscovery;
@@ -321,7 +310,7 @@ export async function addMcpCommands(options) {
321
310
  // Goke already coerced all values via schemas — just extract the relevant keys
322
311
  const parsedArgs = extractToolArguments(cliOptions, inputSchema);
323
312
  const executeWithRetry = async (isRetry = false) => {
324
- const transport = await getTransport(isRetry ? undefined : cachedSessionId);
313
+ const transport = await getTransport();
325
314
  if (!transport) {
326
315
  console.error("MCP transport not available. Run login command first.");
327
316
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goke/mcp",
3
- "version": "0.0.13",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "Dynamically generate CLI commands from MCP server tools",
6
6
  "repository": {
@@ -40,7 +40,7 @@
40
40
  "author": "Tommaso De Rossi, morse <beats.by.morse@gmail.com>",
41
41
  "license": "MIT",
42
42
  "dependencies": {
43
- "@modelcontextprotocol/sdk": "^1.0.0",
43
+ "@modelcontextprotocol/sdk": "^1.30.0",
44
44
  "js-yaml": "^4.1.1"
45
45
  },
46
46
  "peerDependencies": {
@@ -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.12.1"
54
+ "goke": "^6.16.0"
55
55
  },
56
56
  "scripts": {
57
57
  "clean": "rm -rf dist",
@@ -219,11 +219,7 @@ describe("addCliToolsToMcp", () => {
219
219
  "type": "number",
220
220
  "description": "Right operand"
221
221
  }
222
- },
223
- "required": [
224
- "left",
225
- "right"
226
- ]
222
+ }
227
223
  }
228
224
  },
229
225
  {
@@ -265,10 +261,7 @@ describe("addCliToolsToMcp", () => {
265
261
  "type": "boolean",
266
262
  "description": "Dry run flag"
267
263
  }
268
- },
269
- "required": [
270
- "title"
271
- ]
264
+ }
272
265
  }
273
266
  }
274
267
  ]"
@@ -325,11 +318,7 @@ describe("addCliToolsToMcp", () => {
325
318
  "type": "number",
326
319
  "description": "Right operand"
327
320
  }
328
- },
329
- "required": [
330
- "left",
331
- "right"
332
- ]
321
+ }
333
322
  }
334
323
  },
335
324
  {
@@ -371,10 +360,7 @@ describe("addCliToolsToMcp", () => {
371
360
  "type": "boolean",
372
361
  "description": "Dry run flag"
373
362
  }
374
- },
375
- "required": [
376
- "title"
377
- ]
363
+ }
378
364
  }
379
365
  }
380
366
  ]"
@@ -1,7 +1,10 @@
1
1
  // First-run help and stale-cache behavior for addMcpCommands.
2
2
  import http from 'node:http'
3
3
  import { describe, expect, it } from 'vitest'
4
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
5
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
4
6
  import { goke } from 'goke'
7
+ import { addCliToolsToMcp } from '../cli-to-mcp.js'
5
8
  import { addMcpCommands, type CachedMcpTools } from '../index.js'
6
9
 
7
10
  const staleCache = (over: Partial<CachedMcpTools> = {}): CachedMcpTools => ({
@@ -13,7 +16,6 @@ const staleCache = (over: Partial<CachedMcpTools> = {}): CachedMcpTools => ({
13
16
  },
14
17
  ],
15
18
  timestamp: Date.now() - 2 * 60 * 60 * 1000,
16
- sessionId: 'stale-session',
17
19
  ...over,
18
20
  })
19
21
 
@@ -136,4 +138,69 @@ describe('addMcpCommands first-run help', () => {
136
138
 
137
139
  expect(transportCalls).toBe(0)
138
140
  })
141
+
142
+ it('does not pass a session id into getMcpTransport', async () => {
143
+ const sessionIds: Array<string | undefined> = []
144
+ const cli = goke('testcli')
145
+
146
+ await addMcpCommands({
147
+ cli,
148
+ argv: ['find_bookmarks'],
149
+ getMcpTransport: (sessionId?: string) => {
150
+ sessionIds.push(sessionId)
151
+ return null
152
+ },
153
+ loadCache: () => ({
154
+ tools: [
155
+ {
156
+ name: 'find_bookmarks',
157
+ description: 'Find bookmarks',
158
+ inputSchema: { type: 'object', properties: {} },
159
+ },
160
+ ],
161
+ timestamp: Date.now(),
162
+ sessionId: 'should-not-be-reused',
163
+ }),
164
+ saveCache: () => {},
165
+ })
166
+
167
+ const command = cli.commands.find((entry) => entry.name === 'find_bookmarks')
168
+ try {
169
+ await command?.commandAction?.({})
170
+ } catch {
171
+ // transport is null, so the action exits
172
+ }
173
+
174
+ expect(sessionIds).toEqual([undefined])
175
+ })
176
+
177
+ it('caches tools without a session id', async () => {
178
+ const saved: CachedMcpTools[] = []
179
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
180
+ const serverCli = goke('server-cli')
181
+ serverCli.command('ping', 'Ping').action(() => 'pong')
182
+
183
+ const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: {} })
184
+ addCliToolsToMcp({ cli: serverCli, server })
185
+ await server.connect(serverTransport)
186
+
187
+ const cli = goke('testcli')
188
+ try {
189
+ await addMcpCommands({
190
+ cli,
191
+ argv: ['ping'],
192
+ getMcpTransport: () => clientTransport,
193
+ loadCache: () => undefined,
194
+ saveCache: (cache) => {
195
+ if (cache) saved.push(cache)
196
+ },
197
+ })
198
+ } finally {
199
+ await server.close()
200
+ }
201
+
202
+ expect(saved).toHaveLength(1)
203
+ expect(saved[0]?.tools.map((tool) => tool.name)).toEqual(['ping'])
204
+ expect(saved[0]).not.toHaveProperty('sessionId')
205
+ })
139
206
  })