@goke/mcp 0.0.12 → 0.1.0

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/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;
@@ -75,12 +74,14 @@ export interface AddMcpCommandsOptions {
75
74
  getMcpUrl?: () => string | undefined;
76
75
  /**
77
76
  * Returns a transport to connect to the MCP server, or null if not configured.
78
- * If null is returned, no MCP tool commands will be registered.
79
- * @param sessionId - Optional session ID from cache to reuse existing session
80
- *
81
- * @deprecated Use getMcpUrl + oauth instead for simpler setup
77
+ * Use this for stdio servers or any setup `getMcpUrl` cannot express.
78
+ */
79
+ getMcpTransport?: () => Transport | null | Promise<Transport | null>;
80
+ /**
81
+ * Argv used to decide whether to skip live discovery.
82
+ * Defaults to `process.argv.slice(2)`.
82
83
  */
83
- getMcpTransport?: (sessionId?: string) => Transport | null | Promise<Transport | null>;
84
+ argv?: string[];
84
85
  /**
85
86
  * Extra headers for MCP HTTP requests (for example `Authorization`).
86
87
  * Used with `getMcpUrl`. Ignored when `getMcpTransport` is set.
@@ -115,7 +116,6 @@ export interface AddMcpCommandsOptions {
115
116
  * Adds MCP tool commands to a goke CLI instance.
116
117
  *
117
118
  * Tools are cached for 1 hour to avoid connecting on every CLI invocation.
118
- * Session ID is also cached to skip MCP initialization handshake.
119
119
  *
120
120
  * OAuth is lazy - authentication only happens when a 401 error occurs.
121
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;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,SAAS,CAAC,EAAE,MAAM,KAAK,SAAS,GAAG,IAAI,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC;IAEvF;;;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;AA4ID;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAiNlF"}
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
  /**
@@ -131,7 +131,16 @@ function isHelpOrMetaArgv(argv) {
131
131
  return true;
132
132
  return argv.some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v");
133
133
  }
134
- function createTransportWithAuth(url, sessionId, oauthState, oauth, headers) {
134
+ function matchesRegisteredCommand({ argv, cli }) {
135
+ const parts = argv.filter((arg) => !arg.startsWith("-"));
136
+ return cli.commands.some((cmd) => {
137
+ if (!cmd.name)
138
+ return false;
139
+ const nameParts = cmd.name.split(" ");
140
+ return nameParts.every((part, i) => parts[i] === part);
141
+ });
142
+ }
143
+ function createTransportWithAuth({ url, oauthState, oauth, headers, }) {
135
144
  let authProvider;
136
145
  if (oauth && oauthState?.tokens) {
137
146
  authProvider = new FileOAuthProvider({
@@ -148,7 +157,6 @@ function createTransportWithAuth(url, sessionId, oauthState, oauth, headers) {
148
157
  }
149
158
  const hasHeaders = headers && Object.keys(headers).length > 0;
150
159
  return new StreamableHTTPClientTransport(url, {
151
- sessionId,
152
160
  authProvider,
153
161
  requestInit: hasHeaders ? { headers } : undefined,
154
162
  });
@@ -157,16 +165,13 @@ function createTransportWithAuth(url, sessionId, oauthState, oauth, headers) {
157
165
  * Adds MCP tool commands to a goke CLI instance.
158
166
  *
159
167
  * Tools are cached for 1 hour to avoid connecting on every CLI invocation.
160
- * Session ID is also cached to skip MCP initialization handshake.
161
168
  *
162
169
  * OAuth is lazy - authentication only happens when a 401 error occurs.
163
170
  * After successful auth, the operation is automatically retried.
164
171
  */
165
172
  export async function addMcpCommands(options) {
166
- const { cli, commandPrefix = "", clientName = "mcp-cli-client", getMcpUrl, getMcpTransport, getHeaders, oauth, loadCache, saveCache, } = options;
167
- // Helper to get transport - supports both old and new API
168
- const getTransport = async (sessionId) => {
169
- // New API: getMcpUrl + oauth
173
+ const { cli, commandPrefix = "", clientName = "mcp-cli-client", getMcpUrl, getMcpTransport, getHeaders, oauth, loadCache, saveCache, argv = process.argv.slice(2), } = options;
174
+ const getTransport = async () => {
170
175
  if (getMcpUrl) {
171
176
  const mcpUrl = getMcpUrl();
172
177
  if (!mcpUrl) {
@@ -174,11 +179,15 @@ export async function addMcpCommands(options) {
174
179
  }
175
180
  const url = new URL(mcpUrl);
176
181
  const oauthState = oauth?.load();
177
- return createTransportWithAuth(url, sessionId, oauthState, oauth, getHeaders?.());
182
+ return createTransportWithAuth({
183
+ url,
184
+ oauthState,
185
+ oauth,
186
+ headers: getHeaders?.(),
187
+ });
178
188
  }
179
- // Legacy API: getMcpTransport
180
189
  if (getMcpTransport) {
181
- return getMcpTransport(sessionId);
190
+ return getMcpTransport();
182
191
  }
183
192
  return null;
184
193
  };
@@ -209,12 +218,15 @@ export async function addMcpCommands(options) {
209
218
  // Try to use cached tools first (fast path - no network)
210
219
  const cachedTools = loadCache();
211
220
  const isCacheValid = cachedTools && (Date.now() - cachedTools.timestamp) < CACHE_TTL_MS;
212
- const helpOrMeta = isHelpOrMetaArgv(process.argv.slice(2));
221
+ const skipLiveDiscovery = isHelpOrMetaArgv(argv) || matchesRegisteredCommand({ argv, cli });
213
222
  let tools;
214
- let cachedSessionId;
215
223
  if (isCacheValid && cachedTools) {
216
224
  tools = cachedTools.tools;
217
- cachedSessionId = cachedTools.sessionId;
225
+ }
226
+ else if (skipLiveDiscovery) {
227
+ if (cachedTools) {
228
+ tools = cachedTools.tools;
229
+ }
218
230
  }
219
231
  else {
220
232
  const transport = await getTransport();
@@ -224,7 +236,6 @@ export async function addMcpCommands(options) {
224
236
  await client.connect(transport);
225
237
  const result = await client.listTools();
226
238
  tools = result.tools;
227
- const sessionId = transport.sessionId;
228
239
  saveCache({
229
240
  tools: tools.map((t) => ({
230
241
  name: t.name,
@@ -232,12 +243,10 @@ export async function addMcpCommands(options) {
232
243
  inputSchema: t.inputSchema,
233
244
  })),
234
245
  timestamp: Date.now(),
235
- sessionId,
236
246
  });
237
- cachedSessionId = sessionId;
238
247
  }
239
248
  catch (err) {
240
- const shouldAuth = isAuthRequiredError(err) && oauth && getMcpUrl && !helpOrMeta;
249
+ const shouldAuth = isAuthRequiredError(err) && oauth && getMcpUrl && !skipLiveDiscovery;
241
250
  if (shouldAuth) {
242
251
  const mcpUrl = getMcpUrl();
243
252
  if (mcpUrl) {
@@ -247,7 +256,7 @@ export async function addMcpCommands(options) {
247
256
  }
248
257
  }
249
258
  }
250
- if (!helpOrMeta) {
259
+ if (!skipLiveDiscovery) {
251
260
  console.error(`Failed to connect to MCP server: ${err instanceof Error ? err.message : err}`);
252
261
  }
253
262
  }
@@ -257,7 +266,6 @@ export async function addMcpCommands(options) {
257
266
  }
258
267
  if (!tools && cachedTools) {
259
268
  tools = cachedTools.tools;
260
- cachedSessionId = cachedTools.sessionId;
261
269
  }
262
270
  }
263
271
  if (!tools) {
@@ -302,7 +310,7 @@ export async function addMcpCommands(options) {
302
310
  // Goke already coerced all values via schemas — just extract the relevant keys
303
311
  const parsedArgs = extractToolArguments(cliOptions, inputSchema);
304
312
  const executeWithRetry = async (isRetry = false) => {
305
- const transport = await getTransport(isRetry ? undefined : cachedSessionId);
313
+ const transport = await getTransport();
306
314
  if (!transport) {
307
315
  console.error("MCP transport not available. Run login command first.");
308
316
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goke/mcp",
3
- "version": "0.0.12",
3
+ "version": "0.1.0",
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.15.1"
55
55
  },
56
56
  "scripts": {
57
57
  "clean": "rm -rf dist",
@@ -1,6 +1,10 @@
1
1
  // First-run help and stale-cache behavior for addMcpCommands.
2
+ import http from 'node:http'
2
3
  import { describe, expect, it } from 'vitest'
4
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
5
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
3
6
  import { goke } from 'goke'
7
+ import { addCliToolsToMcp } from '../cli-to-mcp.js'
4
8
  import { addMcpCommands, type CachedMcpTools } from '../index.js'
5
9
 
6
10
  const staleCache = (over: Partial<CachedMcpTools> = {}): CachedMcpTools => ({
@@ -15,39 +19,57 @@ const staleCache = (over: Partial<CachedMcpTools> = {}): CachedMcpTools => ({
15
19
  ...over,
16
20
  })
17
21
 
18
- async function withArgv<T>(argv: string[], fn: () => Promise<T>) {
19
- const previous = process.argv
20
- process.argv = ['node', 'testcli', ...argv]
21
- try {
22
- return await fn()
23
- } finally {
24
- process.argv = previous
22
+ function captureErrors() {
23
+ const errors: string[] = []
24
+ const error = console.error
25
+ console.error = (...args: unknown[]) => {
26
+ errors.push(args.map(String).join(' '))
27
+ }
28
+ return {
29
+ errors,
30
+ restore() {
31
+ console.error = error
32
+ },
25
33
  }
26
34
  }
27
35
 
36
+ function listen401() {
37
+ const server = http.createServer((_req, res) => {
38
+ res.writeHead(401, { 'content-type': 'application/json' })
39
+ res.end(JSON.stringify({ error: 'unauthorized' }))
40
+ })
41
+ return new Promise<{ url: string; close: () => Promise<void> }>((resolve) => {
42
+ server.listen(0, '127.0.0.1', () => {
43
+ const addr = server.address()
44
+ if (!addr || typeof addr === 'string') throw new Error('no port')
45
+ resolve({
46
+ url: `http://127.0.0.1:${addr.port}/mcp`,
47
+ close: () => new Promise((done) => server.close(() => done())),
48
+ })
49
+ })
50
+ })
51
+ }
52
+
28
53
  describe('addMcpCommands first-run help', () => {
29
54
  it('lets --help run when there is no token and no cache', async () => {
30
- const errors: string[] = []
31
- const error = console.error
32
- console.error = (...args: unknown[]) => {
33
- errors.push(args.map(String).join(' '))
34
- }
35
-
55
+ const io = captureErrors()
36
56
  const cli = goke('testcli')
37
57
  cli.command('config', 'Save token').action(() => {})
38
58
 
39
- await withArgv(['--help'], async () => {
59
+ try {
40
60
  await addMcpCommands({
41
61
  cli,
62
+ argv: ['--help'],
42
63
  getMcpTransport: () => null,
43
64
  loadCache: () => undefined,
44
65
  saveCache: () => {},
45
66
  })
46
- })
47
- console.error = error
67
+ } finally {
68
+ io.restore()
69
+ }
48
70
 
49
71
  const help = cli.helpText()
50
- expect(errors.join('\n')).not.toMatch(/Failed to connect/)
72
+ expect(io.errors.join('\n')).not.toMatch(/Failed to connect/)
51
73
  expect(help).toMatch(/config/)
52
74
  expect(help).not.toMatch(/find_bookmarks/)
53
75
  })
@@ -55,32 +77,28 @@ describe('addMcpCommands first-run help', () => {
55
77
  it('registers stale cached tools when live fetch is impossible', async () => {
56
78
  const cli = goke('testcli')
57
79
 
58
- await withArgv(['--help'], async () => {
59
- await addMcpCommands({
60
- cli,
61
- getMcpTransport: () => null,
62
- loadCache: () => staleCache(),
63
- saveCache: () => {},
64
- })
80
+ await addMcpCommands({
81
+ cli,
82
+ argv: ['--help'],
83
+ getMcpTransport: () => null,
84
+ loadCache: () => staleCache(),
85
+ saveCache: () => {},
65
86
  })
66
87
 
67
88
  expect(cli.helpText()).toMatch(/find_bookmarks/)
68
89
  })
69
90
 
70
91
  it('does not start OAuth when --help gets a 401', async () => {
92
+ const server = await listen401()
71
93
  const authUrls: string[] = []
72
- const errors: string[] = []
73
- const error = console.error
74
- console.error = (...args: unknown[]) => {
75
- errors.push(args.map(String).join(' '))
76
- }
77
-
94
+ const io = captureErrors()
78
95
  const cli = goke('testcli')
79
96
 
80
- await withArgv(['--help'], async () => {
97
+ try {
81
98
  await addMcpCommands({
82
99
  cli,
83
- getMcpUrl: () => 'http://127.0.0.1:1/mcp',
100
+ argv: ['--help'],
101
+ getMcpUrl: () => server.url,
84
102
  oauth: {
85
103
  clientName: 'test',
86
104
  load: () => undefined,
@@ -92,11 +110,97 @@ describe('addMcpCommands first-run help', () => {
92
110
  loadCache: () => undefined,
93
111
  saveCache: () => {},
94
112
  })
95
- })
96
- console.error = error
113
+ } finally {
114
+ io.restore()
115
+ await server.close()
116
+ }
97
117
 
98
118
  expect(authUrls).toEqual([])
99
- expect(errors.join('\n')).not.toMatch(/Authentication required/)
119
+ expect(io.errors.join('\n')).not.toMatch(/Authentication required/)
100
120
  expect(cli.helpText()).toMatch(/Usage/)
101
121
  })
122
+
123
+ it('does not connect for an already registered command', async () => {
124
+ let transportCalls = 0
125
+ const cli = goke('testcli')
126
+ cli.command('config', 'Save token').action(() => {})
127
+
128
+ await addMcpCommands({
129
+ cli,
130
+ argv: ['config', '--token', 'x'],
131
+ getMcpTransport: () => {
132
+ transportCalls += 1
133
+ return null
134
+ },
135
+ loadCache: () => undefined,
136
+ saveCache: () => {},
137
+ })
138
+
139
+ expect(transportCalls).toBe(0)
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
+ })
102
206
  })
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Proves that one goke cli exposed over the MCP streamable-HTTP
5
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.
6
+ * state (in-memory fs + cwd + env). Isolation comes from the
7
+ * `x-tenant-id` header on each POST, not from `Mcp-Session-Id`.
8
8
  *
9
9
  * Wiring choices worth calling out:
10
10
  *
@@ -18,15 +18,14 @@
18
18
  * into pure request/response JSON. GET SSE opens are answered
19
19
  * with `405`, which the client treats as "server does not offer
20
20
  * SSE" and moves on (see `_startOrAuthSse` in the SDK client).
21
- * - Each session gets its own cli **clone** via
21
+ * - Each HTTP POST gets a fresh transport with
22
+ * `sessionIdGenerator: undefined` and a cli **clone** via
22
23
  * `baseCli.clone({ cwd, env, fs })`. The clone inherits the
23
24
  * command tree but owns its own `{ cwd, env, fs }`, which is
24
25
  * what `runCliTool` forwards into every action through
25
26
  * `ctx.process.*` / `ctx.fs`.
26
27
  */
27
28
 
28
- import { randomUUID } from "node:crypto";
29
- import { Buffer } from "node:buffer";
30
29
  import path from "node:path";
31
30
 
32
31
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
@@ -34,7 +33,6 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
34
33
  import { Server as McpLowLevelServer } from "@modelcontextprotocol/sdk/server/index.js";
35
34
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
36
35
  import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js";
37
- import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
38
36
  import { goke, type Goke, type GokeFs } from "goke";
39
37
  import { describe, expect, it } from "vitest";
40
38
  import { z } from "zod";
@@ -94,7 +92,7 @@ class InMemoryFs implements GokeFs {
94
92
  * One cli definition, reused across tenants. Commands read / write
95
93
  * through `ctx.fs` and resolve paths against `ctx.process.cwd`, so
96
94
  * the *same* code runs per tenant but talks to a tenant-specific
97
- * filesystem when invoked via the session-scoped clone below.
95
+ * filesystem when invoked via the per-request clone below.
98
96
  */
99
97
  function buildBaseCli(): Goke {
100
98
  const cli = goke("notes-app");
@@ -122,9 +120,8 @@ function buildBaseCli(): Goke {
122
120
  // ─── In-process multi-tenant fetch ────────────────────────────────
123
121
 
124
122
  /**
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.
123
+ * Per-tenant state resolved from the `x-tenant-id` header on each
124
+ * request. Each tenant gets its own cwd, env, and in-memory fs.
128
125
  */
129
126
  interface TenantState {
130
127
  cwd: string;
@@ -133,28 +130,27 @@ interface TenantState {
133
130
  }
134
131
 
135
132
  /**
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.
133
+ * Build a `FetchLike` that serves MCP streamable-HTTP traffic with a
134
+ * fresh transport + cli clone on every POST. Tenant identity comes
135
+ * from `x-tenant-id`. There is no `mcp-session-id` map.
144
136
  */
145
137
  function createMultiTenantFetch(options: {
146
138
  baseCli: Goke;
147
139
  resolveTenant: (tenantId: string) => TenantState;
148
140
  }): {
149
141
  fetch: FetchLike;
150
- transports: Map<string, WebStandardStreamableHTTPServerTransport>;
142
+ sessionHeaders: string[];
151
143
  } {
152
144
  const { baseCli, resolveTenant } = options;
153
- const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
145
+ const sessionHeaders: string[] = [];
154
146
 
155
147
  const customFetch: FetchLike = async (url, init) => {
156
148
  const method = (init?.method ?? "GET").toUpperCase();
157
149
  const headers = new Headers(init?.headers);
150
+ const sessionHeader = headers.get("mcp-session-id");
151
+ if (sessionHeader) {
152
+ sessionHeaders.push(sessionHeader);
153
+ }
158
154
 
159
155
  // Pure request/response mode: tell the client there's no SSE
160
156
  // available on GET. `_startOrAuthSse` in the SDK client treats
@@ -177,35 +173,11 @@ function createMultiTenantFetch(options: {
177
173
  }
178
174
  }
179
175
 
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
176
  const request = new Request(url.toString(), {
184
177
  method,
185
178
  headers,
186
179
  });
187
180
 
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
181
  const tenantId = headers.get("x-tenant-id");
210
182
  if (!tenantId) {
211
183
  return new Response("missing x-tenant-id header", { status: 401 });
@@ -225,29 +197,18 @@ function createMultiTenantFetch(options: {
225
197
  addCliToolsToMcp({ cli: tenantCli, server: mcpServer });
226
198
 
227
199
  const transport = new WebStandardStreamableHTTPServerTransport({
228
- sessionIdGenerator: () => randomUUID(),
229
- // Pure request/response — no SSE streaming to clean up.
200
+ sessionIdGenerator: undefined,
230
201
  enableJsonResponse: true,
231
- onsessioninitialized: (sid) => {
232
- transports.set(sid, transport);
233
- },
234
- onsessionclosed: (sid) => {
235
- transports.delete(sid);
236
- },
237
202
  });
238
203
 
239
- transport.onclose = () => {
240
- const sid = transport.sessionId;
241
- if (sid) {
242
- transports.delete(sid);
243
- }
244
- };
245
-
246
204
  await mcpServer.connect(transport);
247
- return transport.handleRequest(request, { parsedBody });
205
+ const response = await transport.handleRequest(request, { parsedBody });
206
+ await transport.close();
207
+ await mcpServer.close();
208
+ return response;
248
209
  };
249
210
 
250
- return { fetch: customFetch, transports };
211
+ return { fetch: customFetch, sessionHeaders };
251
212
  }
252
213
 
253
214
  // ─── Tests ────────────────────────────────────────────────────────
@@ -267,7 +228,7 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
267
228
  fs: new InMemoryFs(),
268
229
  });
269
230
 
270
- const { fetch: tenantFetch } = createMultiTenantFetch({
231
+ const { fetch: tenantFetch, sessionHeaders } = createMultiTenantFetch({
271
232
  baseCli,
272
233
  resolveTenant: (id) => {
273
234
  const tenant = tenants.get(id);
@@ -297,7 +258,7 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
297
258
  return client;
298
259
  }
299
260
 
300
- return { tenants, connectTenant };
261
+ return { tenants, connectTenant, sessionHeaders };
301
262
  }
302
263
 
303
264
  function firstTextBlock(result: Awaited<ReturnType<Client["callTool"]>>): string {
@@ -305,8 +266,8 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
305
266
  return content.find((block) => block.type === "text")?.text ?? "";
306
267
  }
307
268
 
308
- it("routes each session to its own cli clone with tenant-specific cwd/env/fs", async () => {
309
- const { tenants, connectTenant } = setupScenario();
269
+ it("routes each request to its own cli clone with tenant-specific cwd/env/fs", async () => {
270
+ const { tenants, connectTenant, sessionHeaders } = setupScenario();
310
271
 
311
272
  const aliceClient = await connectTenant("tenant-a");
312
273
  const bobClient = await connectTenant("tenant-b");
@@ -320,8 +281,8 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
320
281
  expect(bobTools).toEqual(["load", "save"]);
321
282
 
322
283
  // 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.
284
+ // content. Since each request clones the cli with that tenant's
285
+ // cwd + fs, the writes land in separate Maps.
325
286
  const aliceSave = await aliceClient.callTool({
326
287
  name: "save",
327
288
  arguments: { filename: "notes.txt", content: "alice-secret" },
@@ -359,6 +320,7 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
359
320
  expect([...tenantBFs.files.keys()]).toEqual(["/workspace-b/notes.txt"]);
360
321
  expect(tenantAFs.files.get("/workspace-a/notes.txt")).toBe("alice-secret");
361
322
  expect(tenantBFs.files.get("/workspace-b/notes.txt")).toBe("bob-secret");
323
+ expect(sessionHeaders).toEqual([]);
362
324
  } finally {
363
325
  await aliceClient.close();
364
326
  await bobClient.close();