@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/README.md +21 -52
- package/dist/__test__/add-mcp-commands-help.test.js +134 -35
- package/dist/__test__/http-multi-tenant.test.d.ts +4 -3
- package/dist/__test__/http-multi-tenant.test.d.ts.map +1 -1
- package/dist/__test__/http-multi-tenant.test.js +26 -55
- package/dist/auth.d.ts +4 -2
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +4 -2
- package/dist/index.d.ts +9 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -21
- package/package.json +3 -3
- package/src/__test__/add-mcp-commands-help.test.ts +139 -35
- package/src/__test__/http-multi-tenant.test.ts +29 -67
- package/src/auth.ts +4 -2
- package/src/index.ts +49 -35
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
|
-
*
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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.
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 && !
|
|
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 (!
|
|
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(
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
-
|
|
97
|
+
try {
|
|
81
98
|
await addMcpCommands({
|
|
82
99
|
cli,
|
|
83
|
-
|
|
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
|
-
|
|
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)
|
|
7
|
-
*
|
|
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
|
|
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
|
|
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
|
|
126
|
-
*
|
|
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
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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
|
-
|
|
142
|
+
sessionHeaders: string[];
|
|
151
143
|
} {
|
|
152
144
|
const { baseCli, resolveTenant } = options;
|
|
153
|
-
const
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
324
|
-
//
|
|
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();
|