@goke/mcp 0.0.13 → 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 +18 -51
- package/dist/__test__/add-mcp-commands-help.test.js +63 -1
- 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 +3 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -16
- package/package.json +3 -3
- package/src/__test__/add-mcp-commands-help.test.ts +68 -1
- package/src/__test__/http-multi-tenant.test.ts +29 -67
- package/src/auth.ts +4 -2
- package/src/index.ts +7 -23
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ MCP server Your CLI
|
|
|
25
25
|
|
|
26
26
|
1. **Discover** — calls `tools/list` on the MCP server to get every tool + its JSON Schema
|
|
27
27
|
2. **Register** — creates a CLI command per tool with `--options` derived from the schema
|
|
28
|
-
3. **Cache** —
|
|
28
|
+
3. **Cache** — tool schemas are cached for 1 hour (no network on subsequent runs). Expired cache is still used when a live fetch is impossible (no token, 401 on `--help`)
|
|
29
29
|
4. **Execute** — on invocation, connects to the server and calls the tool with coerced arguments
|
|
30
30
|
5. **OAuth** — if the server returns 401, automatically opens the browser for OAuth, then retries. `--help`, `--version`, `completions`, and no-args never start OAuth
|
|
31
31
|
|
|
@@ -199,8 +199,8 @@ When you expose a cli as a **remote** MCP (over `StreamableHTTPServerTransport`,
|
|
|
199
199
|
The recipe is:
|
|
200
200
|
|
|
201
201
|
1. Define the cli **once**.
|
|
202
|
-
2. On every
|
|
203
|
-
3. Inside command actions, always use the **injected** `ctx`
|
|
202
|
+
2. On every HTTP POST, **clone** the cli with per-tenant `{ cwd, env, fs, stdin }` from the request auth (header, cookie, JWT). Mount it on a fresh `Server` via `addCliToolsToMcp({ cli: tenantClone, server })`.
|
|
203
|
+
3. Inside command actions, always use the **injected** `ctx` (`ctx.fs`, `ctx.process.cwd`, `ctx.process.env`, `ctx.console.log`) instead of the Node globals. `@goke/mcp` wires each tool call into the tenant's cloned context, but only code that goes through `ctx` participates in that isolation.
|
|
204
204
|
|
|
205
205
|
### Write commands against `ctx`
|
|
206
206
|
|
|
@@ -231,64 +231,36 @@ cli
|
|
|
231
231
|
|
|
232
232
|
`ctx.fs` satisfies the `GokeFs` interface — a Node-compatible async filesystem API. You can point it at a real directory, a virtual in-memory store, an S3 bucket adapter, a `memfs`, or anything else you can wrap behind that interface.
|
|
233
233
|
|
|
234
|
-
### Clone the cli per
|
|
234
|
+
### Clone the cli per request
|
|
235
235
|
|
|
236
236
|
The MCP SDK ships `WebStandardStreamableHTTPServerTransport`, which accepts a Web-Standard `Request` and returns a `Response`. That one shape plugs directly into **any** web framework that speaks web-standard: [Spiceflow](https://github.com/remorses/spiceflow), Cloudflare Workers, Deno, Bun, Next.js route handlers, SvelteKit endpoints, or a raw `fetch`-based handler. No Express, no `node:http` wiring, no framework lock-in.
|
|
237
237
|
|
|
238
|
+
Do **not** keep a `Map` of transports keyed by `Mcp-Session-Id`. Tenant state lives in durable storage. Each POST clones the cli from the request auth.
|
|
239
|
+
|
|
238
240
|
You build one `handleMcpRequest(request: Request): Promise<Response>` function and mount it wherever you route HTTP:
|
|
239
241
|
|
|
240
242
|
```ts
|
|
241
|
-
import { randomUUID } from "node:crypto"
|
|
242
243
|
import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js"
|
|
243
244
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
|
|
244
|
-
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"
|
|
245
245
|
import { addCliToolsToMcp } from "@goke/mcp"
|
|
246
246
|
import type { GokeFs } from "goke"
|
|
247
247
|
|
|
248
|
-
// Wherever you store per-user state — DB, Redis, config files, etc.
|
|
249
248
|
declare function resolveTenant(tenantId: string): {
|
|
250
249
|
cwd: string
|
|
251
250
|
env: Record<string, string>
|
|
252
|
-
fs: GokeFs
|
|
251
|
+
fs: GokeFs
|
|
253
252
|
}
|
|
254
253
|
|
|
255
|
-
const transports = new Map<string, WebStandardStreamableHTTPServerTransport>()
|
|
256
|
-
|
|
257
254
|
export async function handleMcpRequest(request: Request): Promise<Response> {
|
|
258
|
-
// Pre-parse the body so we can use it for both routing decisions
|
|
259
|
-
// (is this an `initialize` request?) and as the pre-parsed body
|
|
260
|
-
// forwarded to the transport via `HandleRequestOptions.parsedBody`.
|
|
261
255
|
let parsedBody: unknown
|
|
262
256
|
if (request.method === "POST") {
|
|
263
257
|
parsedBody = await request.clone().json().catch(() => undefined)
|
|
264
258
|
}
|
|
265
259
|
|
|
266
|
-
const sessionId = request.headers.get("mcp-session-id")
|
|
267
|
-
|
|
268
|
-
// Existing session — route to its transport.
|
|
269
|
-
if (sessionId && transports.has(sessionId)) {
|
|
270
|
-
return transports.get(sessionId)!.handleRequest(request, { parsedBody })
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// No session yet — must be an `initialize` request.
|
|
274
|
-
if (!isInitializeRequest(parsedBody)) {
|
|
275
|
-
return Response.json(
|
|
276
|
-
{
|
|
277
|
-
jsonrpc: "2.0",
|
|
278
|
-
error: { code: -32000, message: "Bad Request: No valid session ID provided" },
|
|
279
|
-
id: null,
|
|
280
|
-
},
|
|
281
|
-
{ status: 400 },
|
|
282
|
-
)
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// Derive the tenant from whatever header/cookie/JWT you use.
|
|
286
260
|
const tenantId = request.headers.get("x-tenant-id")
|
|
287
261
|
if (!tenantId) return new Response("missing x-tenant-id", { status: 401 })
|
|
288
262
|
const tenant = resolveTenant(tenantId)
|
|
289
263
|
|
|
290
|
-
// Clone the base cli with tenant-specific cwd/env/fs. Every tool
|
|
291
|
-
// call on this session now sees the tenant's state via `ctx`.
|
|
292
264
|
const tenantCli = baseCli.clone({
|
|
293
265
|
cwd: tenant.cwd,
|
|
294
266
|
env: { ...tenant.env, TENANT_ID: tenantId },
|
|
@@ -302,22 +274,17 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
|
|
302
274
|
addCliToolsToMcp({ cli: tenantCli, server: mcpServer })
|
|
303
275
|
|
|
304
276
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
305
|
-
sessionIdGenerator:
|
|
306
|
-
enableJsonResponse: true,
|
|
307
|
-
onsessioninitialized: (sid) => {
|
|
308
|
-
transports.set(sid, transport)
|
|
309
|
-
},
|
|
310
|
-
onsessionclosed: (sid) => {
|
|
311
|
-
transports.delete(sid)
|
|
312
|
-
},
|
|
277
|
+
sessionIdGenerator: undefined,
|
|
278
|
+
enableJsonResponse: true,
|
|
313
279
|
})
|
|
314
|
-
transport.onclose = () => {
|
|
315
|
-
const sid = transport.sessionId
|
|
316
|
-
if (sid) transports.delete(sid)
|
|
317
|
-
}
|
|
318
280
|
|
|
319
281
|
await mcpServer.connect(transport)
|
|
320
|
-
|
|
282
|
+
try {
|
|
283
|
+
return await transport.handleRequest(request, { parsedBody })
|
|
284
|
+
} finally {
|
|
285
|
+
await transport.close()
|
|
286
|
+
await mcpServer.close()
|
|
287
|
+
}
|
|
321
288
|
}
|
|
322
289
|
```
|
|
323
290
|
|
|
@@ -353,7 +320,7 @@ export async function POST(request: Request) {
|
|
|
353
320
|
|
|
354
321
|
**Key guarantees**
|
|
355
322
|
|
|
356
|
-
- Every tool call
|
|
323
|
+
- Every tool call on a request runs against `tenantCli`'s `cwd` / `env` / `fs`, not the base cli's and not another tenant's.
|
|
357
324
|
- `ctx.console.log` / `ctx.console.error` / `ctx.process.stdout.write` / `ctx.process.stderr.write` are captured into the `CallToolResult.content`. They never reach the host process stdio, so they can't corrupt the JSON-RPC channel or leak between users.
|
|
358
325
|
- `ctx.process.exit(code)` throws `GokeProcessExit` instead of killing the server. The tool call resolves as `{ isError: code !== 0, content: [captured output] }` and the next request keeps running.
|
|
359
326
|
- Actions that `throw` are caught and returned as `{ isError: true, content: [message, stderr] }`.
|
|
@@ -486,7 +453,7 @@ Registers MCP tool commands on a goke CLI instance.
|
|
|
486
453
|
|--------|------|---------|-------------|
|
|
487
454
|
| `cli` | `Goke` | **required** | The goke CLI instance to add commands to |
|
|
488
455
|
| `getMcpUrl` | `() => string \| undefined` | — | Returns the MCP server URL. Return the URL even when the user is not logged in so `--help` still works |
|
|
489
|
-
| `getMcpTransport` | `(
|
|
456
|
+
| `getMcpTransport` | `() => Transport \| null` | — | Custom transport. Use for stdio or anything `getMcpUrl` cannot express |
|
|
490
457
|
| `getHeaders` | `() => Record<string, string> \| undefined` | — | Extra HTTP headers (for example `Authorization`). Used with `getMcpUrl` |
|
|
491
458
|
| `argv` | `string[]` | `process.argv.slice(2)` | Args used to skip live discovery on help and already registered commands |
|
|
492
459
|
| `commandPrefix` | `string` | `''` | Prefix for commands (e.g. `'mcp'` makes `mcp notion-search`) |
|
|
@@ -554,7 +521,7 @@ Tokens are persisted via the `oauth.save()` callback you provide, so subsequent
|
|
|
554
521
|
|
|
555
522
|
## Caching
|
|
556
523
|
|
|
557
|
-
|
|
524
|
+
Tool schemas are cached for **1 hour** so `--help` and command registration do not hit the network on every invocation. The cache is managed through the `loadCache`/`saveCache` callbacks. You control where it is stored (file, database, env, etc.). Each tool call still opens a new MCP connection. There is no `Mcp-Session-Id` reuse.
|
|
558
525
|
|
|
559
526
|
When the cache expires or a tool call fails, the cache is cleared and tools are re-fetched on the next run.
|
|
560
527
|
|
|
@@ -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 } from '../index.js';
|
|
6
9
|
const staleCache = (over = {}) => ({
|
|
7
10
|
tools: [
|
|
@@ -12,7 +15,6 @@ const staleCache = (over = {}) => ({
|
|
|
12
15
|
},
|
|
13
16
|
],
|
|
14
17
|
timestamp: Date.now() - 2 * 60 * 60 * 1000,
|
|
15
|
-
sessionId: 'stale-session',
|
|
16
18
|
...over,
|
|
17
19
|
});
|
|
18
20
|
function captureErrors() {
|
|
@@ -124,4 +126,64 @@ describe('addMcpCommands first-run help', () => {
|
|
|
124
126
|
});
|
|
125
127
|
expect(transportCalls).toBe(0);
|
|
126
128
|
});
|
|
129
|
+
it('does not pass a session id into getMcpTransport', async () => {
|
|
130
|
+
const sessionIds = [];
|
|
131
|
+
const cli = goke('testcli');
|
|
132
|
+
await addMcpCommands({
|
|
133
|
+
cli,
|
|
134
|
+
argv: ['find_bookmarks'],
|
|
135
|
+
getMcpTransport: (sessionId) => {
|
|
136
|
+
sessionIds.push(sessionId);
|
|
137
|
+
return null;
|
|
138
|
+
},
|
|
139
|
+
loadCache: () => ({
|
|
140
|
+
tools: [
|
|
141
|
+
{
|
|
142
|
+
name: 'find_bookmarks',
|
|
143
|
+
description: 'Find bookmarks',
|
|
144
|
+
inputSchema: { type: 'object', properties: {} },
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
timestamp: Date.now(),
|
|
148
|
+
sessionId: 'should-not-be-reused',
|
|
149
|
+
}),
|
|
150
|
+
saveCache: () => { },
|
|
151
|
+
});
|
|
152
|
+
const command = cli.commands.find((entry) => entry.name === 'find_bookmarks');
|
|
153
|
+
try {
|
|
154
|
+
await command?.commandAction?.({});
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// transport is null, so the action exits
|
|
158
|
+
}
|
|
159
|
+
expect(sessionIds).toEqual([undefined]);
|
|
160
|
+
});
|
|
161
|
+
it('caches tools without a session id', async () => {
|
|
162
|
+
const saved = [];
|
|
163
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
164
|
+
const serverCli = goke('server-cli');
|
|
165
|
+
serverCli.command('ping', 'Ping').action(() => 'pong');
|
|
166
|
+
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
|
|
167
|
+
addCliToolsToMcp({ cli: serverCli, server });
|
|
168
|
+
await server.connect(serverTransport);
|
|
169
|
+
const cli = goke('testcli');
|
|
170
|
+
try {
|
|
171
|
+
await addMcpCommands({
|
|
172
|
+
cli,
|
|
173
|
+
argv: ['ping'],
|
|
174
|
+
getMcpTransport: () => clientTransport,
|
|
175
|
+
loadCache: () => undefined,
|
|
176
|
+
saveCache: (cache) => {
|
|
177
|
+
if (cache)
|
|
178
|
+
saved.push(cache);
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
await server.close();
|
|
184
|
+
}
|
|
185
|
+
expect(saved).toHaveLength(1);
|
|
186
|
+
expect(saved[0]?.tools.map((tool) => tool.name)).toEqual(['ping']);
|
|
187
|
+
expect(saved[0]).not.toHaveProperty('sessionId');
|
|
188
|
+
});
|
|
127
189
|
});
|
|
@@ -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,7 +18,8 @@
|
|
|
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
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-multi-tenant.test.d.ts","sourceRoot":"","sources":["../../src/__test__/http-multi-tenant.test.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"http-multi-tenant.test.d.ts","sourceRoot":"","sources":["../../src/__test__/http-multi-tenant.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG"}
|
|
@@ -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,19 +18,18 @@
|
|
|
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
|
-
import { randomUUID } from "node:crypto";
|
|
28
28
|
import path from "node:path";
|
|
29
29
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
30
30
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
31
31
|
import { Server as McpLowLevelServer } from "@modelcontextprotocol/sdk/server/index.js";
|
|
32
32
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
33
|
-
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
34
33
|
import { goke } from "goke";
|
|
35
34
|
import { describe, expect, it } from "vitest";
|
|
36
35
|
import { z } from "zod";
|
|
@@ -80,7 +79,7 @@ class InMemoryFs {
|
|
|
80
79
|
* One cli definition, reused across tenants. Commands read / write
|
|
81
80
|
* through `ctx.fs` and resolve paths against `ctx.process.cwd`, so
|
|
82
81
|
* the *same* code runs per tenant but talks to a tenant-specific
|
|
83
|
-
* filesystem when invoked via the
|
|
82
|
+
* filesystem when invoked via the per-request clone below.
|
|
84
83
|
*/
|
|
85
84
|
function buildBaseCli() {
|
|
86
85
|
const cli = goke("notes-app");
|
|
@@ -102,21 +101,20 @@ function buildBaseCli() {
|
|
|
102
101
|
return cli;
|
|
103
102
|
}
|
|
104
103
|
/**
|
|
105
|
-
* Build a `FetchLike` that
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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.
|
|
104
|
+
* Build a `FetchLike` that serves MCP streamable-HTTP traffic with a
|
|
105
|
+
* fresh transport + cli clone on every POST. Tenant identity comes
|
|
106
|
+
* from `x-tenant-id`. There is no `mcp-session-id` map.
|
|
113
107
|
*/
|
|
114
108
|
function createMultiTenantFetch(options) {
|
|
115
109
|
const { baseCli, resolveTenant } = options;
|
|
116
|
-
const
|
|
110
|
+
const sessionHeaders = [];
|
|
117
111
|
const customFetch = async (url, init) => {
|
|
118
112
|
const method = (init?.method ?? "GET").toUpperCase();
|
|
119
113
|
const headers = new Headers(init?.headers);
|
|
114
|
+
const sessionHeader = headers.get("mcp-session-id");
|
|
115
|
+
if (sessionHeader) {
|
|
116
|
+
sessionHeaders.push(sessionHeader);
|
|
117
|
+
}
|
|
120
118
|
// Pure request/response mode: tell the client there's no SSE
|
|
121
119
|
// available on GET. `_startOrAuthSse` in the SDK client treats
|
|
122
120
|
// 405 as "server does not offer SSE" and moves on gracefully.
|
|
@@ -136,28 +134,10 @@ function createMultiTenantFetch(options) {
|
|
|
136
134
|
parsedBody = JSON.parse(bodyText);
|
|
137
135
|
}
|
|
138
136
|
}
|
|
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
137
|
const request = new Request(url.toString(), {
|
|
143
138
|
method,
|
|
144
139
|
headers,
|
|
145
140
|
});
|
|
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
141
|
const tenantId = headers.get("x-tenant-id");
|
|
162
142
|
if (!tenantId) {
|
|
163
143
|
return new Response("missing x-tenant-id header", { status: 401 });
|
|
@@ -171,26 +151,16 @@ function createMultiTenantFetch(options) {
|
|
|
171
151
|
const mcpServer = new McpLowLevelServer({ name: "notes-app-mcp", version: "1.0.0" }, { capabilities: {} });
|
|
172
152
|
addCliToolsToMcp({ cli: tenantCli, server: mcpServer });
|
|
173
153
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
174
|
-
sessionIdGenerator:
|
|
175
|
-
// Pure request/response — no SSE streaming to clean up.
|
|
154
|
+
sessionIdGenerator: undefined,
|
|
176
155
|
enableJsonResponse: true,
|
|
177
|
-
onsessioninitialized: (sid) => {
|
|
178
|
-
transports.set(sid, transport);
|
|
179
|
-
},
|
|
180
|
-
onsessionclosed: (sid) => {
|
|
181
|
-
transports.delete(sid);
|
|
182
|
-
},
|
|
183
156
|
});
|
|
184
|
-
transport.onclose = () => {
|
|
185
|
-
const sid = transport.sessionId;
|
|
186
|
-
if (sid) {
|
|
187
|
-
transports.delete(sid);
|
|
188
|
-
}
|
|
189
|
-
};
|
|
190
157
|
await mcpServer.connect(transport);
|
|
191
|
-
|
|
158
|
+
const response = await transport.handleRequest(request, { parsedBody });
|
|
159
|
+
await transport.close();
|
|
160
|
+
await mcpServer.close();
|
|
161
|
+
return response;
|
|
192
162
|
};
|
|
193
|
-
return { fetch: customFetch,
|
|
163
|
+
return { fetch: customFetch, sessionHeaders };
|
|
194
164
|
}
|
|
195
165
|
// ─── Tests ────────────────────────────────────────────────────────
|
|
196
166
|
describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () => {
|
|
@@ -207,7 +177,7 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
207
177
|
env: { ROLE: "reader" },
|
|
208
178
|
fs: new InMemoryFs(),
|
|
209
179
|
});
|
|
210
|
-
const { fetch: tenantFetch } = createMultiTenantFetch({
|
|
180
|
+
const { fetch: tenantFetch, sessionHeaders } = createMultiTenantFetch({
|
|
211
181
|
baseCli,
|
|
212
182
|
resolveTenant: (id) => {
|
|
213
183
|
const tenant = tenants.get(id);
|
|
@@ -232,14 +202,14 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
232
202
|
await client.connect(transport);
|
|
233
203
|
return client;
|
|
234
204
|
}
|
|
235
|
-
return { tenants, connectTenant };
|
|
205
|
+
return { tenants, connectTenant, sessionHeaders };
|
|
236
206
|
}
|
|
237
207
|
function firstTextBlock(result) {
|
|
238
208
|
const content = result.content ?? [];
|
|
239
209
|
return content.find((block) => block.type === "text")?.text ?? "";
|
|
240
210
|
}
|
|
241
|
-
it("routes each
|
|
242
|
-
const { tenants, connectTenant } = setupScenario();
|
|
211
|
+
it("routes each request to its own cli clone with tenant-specific cwd/env/fs", async () => {
|
|
212
|
+
const { tenants, connectTenant, sessionHeaders } = setupScenario();
|
|
243
213
|
const aliceClient = await connectTenant("tenant-a");
|
|
244
214
|
const bobClient = await connectTenant("tenant-b");
|
|
245
215
|
try {
|
|
@@ -250,8 +220,8 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
250
220
|
expect(aliceTools).toEqual(["load", "save"]);
|
|
251
221
|
expect(bobTools).toEqual(["load", "save"]);
|
|
252
222
|
// Both tenants write a file called `notes.txt` with different
|
|
253
|
-
// content. Since each
|
|
254
|
-
//
|
|
223
|
+
// content. Since each request clones the cli with that tenant's
|
|
224
|
+
// cwd + fs, the writes land in separate Maps.
|
|
255
225
|
const aliceSave = await aliceClient.callTool({
|
|
256
226
|
name: "save",
|
|
257
227
|
arguments: { filename: "notes.txt", content: "alice-secret" },
|
|
@@ -285,6 +255,7 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
285
255
|
expect([...tenantBFs.files.keys()]).toEqual(["/workspace-b/notes.txt"]);
|
|
286
256
|
expect(tenantAFs.files.get("/workspace-a/notes.txt")).toBe("alice-secret");
|
|
287
257
|
expect(tenantBFs.files.get("/workspace-b/notes.txt")).toBe("bob-secret");
|
|
258
|
+
expect(sessionHeaders).toEqual([]);
|
|
288
259
|
}
|
|
289
260
|
finally {
|
|
290
261
|
await aliceClient.close();
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
package/dist/auth.d.ts.map
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
28
|
-
*
|
|
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
|
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?: (
|
|
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.
|
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
|
/**
|
|
@@ -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,
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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,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
|
})
|
|
@@ -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();
|
package/src/auth.ts
CHANGED
|
@@ -29,8 +29,10 @@ async function openBrowser(url: string): Promise<void> {
|
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Start the OAuth flow for an MCP server.
|
|
32
|
-
*
|
|
33
|
-
*
|
|
32
|
+
*
|
|
33
|
+
* Used internally by addMcpCommands on 401 errors, but also available
|
|
34
|
+
* for CLIs that need explicit control over the auth flow (e.g. a login
|
|
35
|
+
* command that runs the flow in a background daemon).
|
|
34
36
|
*
|
|
35
37
|
* This function:
|
|
36
38
|
* 1. Starts a local callback server on a random port
|
package/src/index.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)
|
|
@@ -48,13 +47,14 @@ import { wrapJsonSchema } from "goke";
|
|
|
48
47
|
import yaml from "js-yaml";
|
|
49
48
|
import { FileOAuthProvider } from "./oauth-provider.js";
|
|
50
49
|
import { startOAuthFlow, isAuthRequiredError } from "./auth.js";
|
|
50
|
+
export { startOAuthFlow } from "./auth.js";
|
|
51
51
|
import type { McpOAuthConfig, McpOAuthState } from "./types.js";
|
|
52
52
|
export { addCliToolsToMcp, createMcpAction } from "./cli-to-mcp.js";
|
|
53
53
|
export type { AddCliToolsToMcpOptions, CreateMcpActionOptions } from "./cli-to-mcp.js";
|
|
54
54
|
|
|
55
|
-
// Public exports
|
|
55
|
+
// Public exports
|
|
56
56
|
export type { Transport };
|
|
57
|
-
export type { McpOAuthConfig, McpOAuthState } from "./types.js";
|
|
57
|
+
export type { McpOAuthConfig, McpOAuthState, StartOAuthFlowOptions, OAuthFlowResult } from "./types.js";
|
|
58
58
|
|
|
59
59
|
export interface CachedMcpTools {
|
|
60
60
|
tools: Array<{
|
|
@@ -63,7 +63,6 @@ export interface CachedMcpTools {
|
|
|
63
63
|
inputSchema?: unknown;
|
|
64
64
|
}>;
|
|
65
65
|
timestamp: number;
|
|
66
|
-
sessionId?: string;
|
|
67
66
|
}
|
|
68
67
|
|
|
69
68
|
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
@@ -91,9 +90,8 @@ export interface AddMcpCommandsOptions {
|
|
|
91
90
|
/**
|
|
92
91
|
* Returns a transport to connect to the MCP server, or null if not configured.
|
|
93
92
|
* Use this for stdio servers or any setup `getMcpUrl` cannot express.
|
|
94
|
-
* @param sessionId - Optional session ID from a still-valid cache
|
|
95
93
|
*/
|
|
96
|
-
getMcpTransport?: (
|
|
94
|
+
getMcpTransport?: () => Transport | null | Promise<Transport | null>;
|
|
97
95
|
|
|
98
96
|
/**
|
|
99
97
|
* Argv used to decide whether to skip live discovery.
|
|
@@ -251,13 +249,11 @@ function matchesRegisteredCommand({ argv, cli }: { argv: string[]; cli: Goke })
|
|
|
251
249
|
|
|
252
250
|
function createTransportWithAuth({
|
|
253
251
|
url,
|
|
254
|
-
sessionId,
|
|
255
252
|
oauthState,
|
|
256
253
|
oauth,
|
|
257
254
|
headers,
|
|
258
255
|
}: {
|
|
259
256
|
url: URL
|
|
260
|
-
sessionId?: string
|
|
261
257
|
oauthState?: McpOAuthState
|
|
262
258
|
oauth?: McpOAuthConfig
|
|
263
259
|
headers?: Record<string, string>
|
|
@@ -280,7 +276,6 @@ function createTransportWithAuth({
|
|
|
280
276
|
|
|
281
277
|
const hasHeaders = headers && Object.keys(headers).length > 0;
|
|
282
278
|
return new StreamableHTTPClientTransport(url, {
|
|
283
|
-
sessionId,
|
|
284
279
|
authProvider,
|
|
285
280
|
requestInit: hasHeaders ? { headers } : undefined,
|
|
286
281
|
});
|
|
@@ -292,7 +287,6 @@ function createTransportWithAuth({
|
|
|
292
287
|
* Adds MCP tool commands to a goke CLI instance.
|
|
293
288
|
*
|
|
294
289
|
* Tools are cached for 1 hour to avoid connecting on every CLI invocation.
|
|
295
|
-
* Session ID is also cached to skip MCP initialization handshake.
|
|
296
290
|
*
|
|
297
291
|
* OAuth is lazy - authentication only happens when a 401 error occurs.
|
|
298
292
|
* After successful auth, the operation is automatically retried.
|
|
@@ -311,9 +305,7 @@ export async function addMcpCommands(options: AddMcpCommandsOptions): Promise<vo
|
|
|
311
305
|
argv = process.argv.slice(2),
|
|
312
306
|
} = options;
|
|
313
307
|
|
|
314
|
-
|
|
315
|
-
const getTransport = async (sessionId?: string): Promise<Transport | null> => {
|
|
316
|
-
// New API: getMcpUrl + oauth
|
|
308
|
+
const getTransport = async (): Promise<Transport | null> => {
|
|
317
309
|
if (getMcpUrl) {
|
|
318
310
|
const mcpUrl = getMcpUrl();
|
|
319
311
|
if (!mcpUrl) {
|
|
@@ -325,16 +317,14 @@ export async function addMcpCommands(options: AddMcpCommandsOptions): Promise<vo
|
|
|
325
317
|
|
|
326
318
|
return createTransportWithAuth({
|
|
327
319
|
url,
|
|
328
|
-
sessionId,
|
|
329
320
|
oauthState,
|
|
330
321
|
oauth,
|
|
331
322
|
headers: getHeaders?.(),
|
|
332
323
|
});
|
|
333
324
|
}
|
|
334
325
|
|
|
335
|
-
// Custom / stdio transport
|
|
336
326
|
if (getMcpTransport) {
|
|
337
|
-
return getMcpTransport(
|
|
327
|
+
return getMcpTransport();
|
|
338
328
|
}
|
|
339
329
|
|
|
340
330
|
return null;
|
|
@@ -376,11 +366,9 @@ export async function addMcpCommands(options: AddMcpCommandsOptions): Promise<vo
|
|
|
376
366
|
isHelpOrMetaArgv(argv) || matchesRegisteredCommand({ argv, cli });
|
|
377
367
|
|
|
378
368
|
let tools: CachedMcpTools["tools"] | undefined;
|
|
379
|
-
let cachedSessionId: string | undefined;
|
|
380
369
|
|
|
381
370
|
if (isCacheValid && cachedTools) {
|
|
382
371
|
tools = cachedTools.tools;
|
|
383
|
-
cachedSessionId = cachedTools.sessionId;
|
|
384
372
|
} else if (skipLiveDiscovery) {
|
|
385
373
|
if (cachedTools) {
|
|
386
374
|
tools = cachedTools.tools;
|
|
@@ -394,8 +382,6 @@ export async function addMcpCommands(options: AddMcpCommandsOptions): Promise<vo
|
|
|
394
382
|
const result = await client.listTools();
|
|
395
383
|
tools = result.tools;
|
|
396
384
|
|
|
397
|
-
const sessionId = (transport as { sessionId?: string }).sessionId;
|
|
398
|
-
|
|
399
385
|
saveCache({
|
|
400
386
|
tools: tools.map((t) => ({
|
|
401
387
|
name: t.name,
|
|
@@ -403,9 +389,7 @@ export async function addMcpCommands(options: AddMcpCommandsOptions): Promise<vo
|
|
|
403
389
|
inputSchema: t.inputSchema,
|
|
404
390
|
})),
|
|
405
391
|
timestamp: Date.now(),
|
|
406
|
-
sessionId,
|
|
407
392
|
});
|
|
408
|
-
cachedSessionId = sessionId;
|
|
409
393
|
} catch (err) {
|
|
410
394
|
const shouldAuth = isAuthRequiredError(err) && oauth && getMcpUrl && !skipLiveDiscovery;
|
|
411
395
|
if (shouldAuth) {
|
|
@@ -480,7 +464,7 @@ export async function addMcpCommands(options: AddMcpCommandsOptions): Promise<vo
|
|
|
480
464
|
const parsedArgs = extractToolArguments(cliOptions, inputSchema);
|
|
481
465
|
|
|
482
466
|
const executeWithRetry = async (isRetry = false): Promise<void> => {
|
|
483
|
-
const transport = await getTransport(
|
|
467
|
+
const transport = await getTransport();
|
|
484
468
|
if (!transport) {
|
|
485
469
|
console.error("MCP transport not available. Run login command first.");
|
|
486
470
|
process.exit(1);
|