@goke/mcp 0.0.13 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -52
- package/dist/__test__/add-cli-tools-to-mcp.test.js +4 -18
- package/dist/__test__/add-mcp-commands-help.test.js +63 -1
- package/dist/__test__/create-mcp-action.test.js +114 -0
- package/dist/__test__/http-multi-tenant.test.d.ts +14 -9
- package/dist/__test__/http-multi-tenant.test.d.ts.map +1 -1
- package/dist/__test__/http-multi-tenant.test.js +63 -78
- package/dist/auth.d.ts +4 -2
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +4 -2
- package/dist/cli-to-mcp.d.ts.map +1 -1
- package/dist/cli-to-mcp.js +7 -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-cli-tools-to-mcp.test.ts +4 -18
- package/src/__test__/add-mcp-commands-help.test.ts +68 -1
- package/src/__test__/create-mcp-action.test.ts +147 -0
- package/src/__test__/http-multi-tenant.test.ts +69 -90
- package/src/auth.ts +4 -2
- package/src/cli-to-mcp.ts +9 -1
- package/src/index.ts +7 -23
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Multi-tenant remote-MCP test.
|
|
3
3
|
*
|
|
4
|
-
* Proves that one goke cli exposed over
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Proves that one goke cli exposed over stateless MCP streamable HTTP
|
|
5
|
+
* can serve multiple concurrent users with fully isolated state
|
|
6
|
+
* (in-memory fs + cwd + env) — no shared host process stdio, no
|
|
7
|
+
* cross-tenant leaks, no mcp-session-id map. Isolation comes from
|
|
8
|
+
* the `x-tenant-id` header on each POST, not from `Mcp-Session-Id`.
|
|
8
9
|
*
|
|
9
10
|
* Wiring choices worth calling out:
|
|
10
11
|
*
|
|
@@ -14,23 +15,25 @@
|
|
|
14
15
|
* transport's `fetch` hook without ever binding a TCP socket
|
|
15
16
|
* or spinning up `node:http` / Express. Same wire protocol,
|
|
16
17
|
* zero sockets.
|
|
17
|
-
* - `
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* - `sessionIdGenerator: undefined` is stateless mode. The
|
|
19
|
+
* transport does not emit or expect `mcp-session-id`. Each
|
|
20
|
+
* POST builds a fresh Server + transport, then closes them.
|
|
21
|
+
* - `enableJsonResponse: true` switches the transport off SSE
|
|
22
|
+
* and into pure request/response JSON. GET SSE opens are
|
|
23
|
+
* answered with `405`, which the client treats as "server
|
|
24
|
+
* does not offer SSE" and moves on (see `_startOrAuthSse`
|
|
25
|
+
* in the SDK client).
|
|
26
|
+
* - Each request gets its own cli **clone** via
|
|
22
27
|
* `baseCli.clone({ cwd, env, fs })`. The clone inherits the
|
|
23
28
|
* command tree but owns its own `{ cwd, env, fs }`, which is
|
|
24
29
|
* what `runCliTool` forwards into every action through
|
|
25
30
|
* `ctx.process.*` / `ctx.fs`.
|
|
26
31
|
*/
|
|
27
|
-
import { randomUUID } from "node:crypto";
|
|
28
32
|
import path from "node:path";
|
|
29
33
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
30
34
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
31
35
|
import { Server as McpLowLevelServer } from "@modelcontextprotocol/sdk/server/index.js";
|
|
32
36
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
33
|
-
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
34
37
|
import { goke } from "goke";
|
|
35
38
|
import { describe, expect, it } from "vitest";
|
|
36
39
|
import { z } from "zod";
|
|
@@ -80,7 +83,7 @@ class InMemoryFs {
|
|
|
80
83
|
* One cli definition, reused across tenants. Commands read / write
|
|
81
84
|
* through `ctx.fs` and resolve paths against `ctx.process.cwd`, so
|
|
82
85
|
* the *same* code runs per tenant but talks to a tenant-specific
|
|
83
|
-
* filesystem when invoked via the
|
|
86
|
+
* filesystem when invoked via the per-request clone below.
|
|
84
87
|
*/
|
|
85
88
|
function buildBaseCli() {
|
|
86
89
|
const cli = goke("notes-app");
|
|
@@ -102,32 +105,30 @@ function buildBaseCli() {
|
|
|
102
105
|
return cli;
|
|
103
106
|
}
|
|
104
107
|
/**
|
|
105
|
-
* Build a `FetchLike` that
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* is keyed by `mcp-session-id
|
|
109
|
-
* via the `x-tenant-id` header.
|
|
110
|
-
*
|
|
111
|
-
* Returns both the custom fetch and the transports map so tests can
|
|
112
|
-
* inspect session state if needed.
|
|
108
|
+
* Build a `FetchLike` that handles each MCP POST with a fresh
|
|
109
|
+
* `WebStandardStreamableHTTPServerTransport` and cli clone.
|
|
110
|
+
* Tenant identity comes from `x-tenant-id` on every request.
|
|
111
|
+
* Nothing is keyed by `mcp-session-id`.
|
|
113
112
|
*/
|
|
114
113
|
function createMultiTenantFetch(options) {
|
|
115
114
|
const { baseCli, resolveTenant } = options;
|
|
116
|
-
const
|
|
115
|
+
const sessionHeaders = [];
|
|
117
116
|
const customFetch = async (url, init) => {
|
|
118
117
|
const method = (init?.method ?? "GET").toUpperCase();
|
|
119
118
|
const headers = new Headers(init?.headers);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
119
|
+
const sessionHeader = headers.get("mcp-session-id");
|
|
120
|
+
if (sessionHeader) {
|
|
121
|
+
sessionHeaders.push(sessionHeader);
|
|
122
|
+
}
|
|
123
|
+
const origin = headers.get("origin");
|
|
124
|
+
if (origin && origin !== "http://in-memory-mcp.test") {
|
|
125
|
+
return new Response(null, { status: 403 });
|
|
126
|
+
}
|
|
127
|
+
if (method !== "POST") {
|
|
128
|
+
return new Response(null, { status: 405, headers: { Allow: "POST" } });
|
|
125
129
|
}
|
|
126
|
-
// Parse POST body once and hand it to the transport via
|
|
127
|
-
// `parsedBody` in `HandleRequestOptions` so we don't have to
|
|
128
|
-
// worry about Request body streams being single-use.
|
|
129
130
|
let parsedBody = undefined;
|
|
130
|
-
if (
|
|
131
|
+
if (init?.body != null) {
|
|
131
132
|
const rawBody = init.body;
|
|
132
133
|
const bodyText = typeof rawBody === "string"
|
|
133
134
|
? rawBody
|
|
@@ -136,28 +137,10 @@ function createMultiTenantFetch(options) {
|
|
|
136
137
|
parsedBody = JSON.parse(bodyText);
|
|
137
138
|
}
|
|
138
139
|
}
|
|
139
|
-
// Rebuild a plain Request with the same method + headers. The
|
|
140
|
-
// transport reads accept/content-type from here and uses
|
|
141
|
-
// `parsedBody` for the actual JSON-RPC payload.
|
|
142
140
|
const request = new Request(url.toString(), {
|
|
143
141
|
method,
|
|
144
142
|
headers,
|
|
145
143
|
});
|
|
146
|
-
const sessionId = headers.get("mcp-session-id");
|
|
147
|
-
// Existing session: route to its transport.
|
|
148
|
-
if (sessionId && transports.has(sessionId)) {
|
|
149
|
-
return transports.get(sessionId).handleRequest(request, { parsedBody });
|
|
150
|
-
}
|
|
151
|
-
// New session: must be an initialize POST.
|
|
152
|
-
if (method !== "POST" || !isInitializeRequest(parsedBody)) {
|
|
153
|
-
return new Response(JSON.stringify({
|
|
154
|
-
jsonrpc: "2.0",
|
|
155
|
-
error: { code: -32000, message: "Bad Request: No valid session ID provided" },
|
|
156
|
-
id: null,
|
|
157
|
-
}), { status: 400, headers: { "content-type": "application/json" } });
|
|
158
|
-
}
|
|
159
|
-
// Resolve the tenant from the custom header, build a cli clone
|
|
160
|
-
// with its cwd/env/fs, and spin up a session-scoped MCP server.
|
|
161
144
|
const tenantId = headers.get("x-tenant-id");
|
|
162
145
|
if (!tenantId) {
|
|
163
146
|
return new Response("missing x-tenant-id header", { status: 401 });
|
|
@@ -171,26 +154,19 @@ function createMultiTenantFetch(options) {
|
|
|
171
154
|
const mcpServer = new McpLowLevelServer({ name: "notes-app-mcp", version: "1.0.0" }, { capabilities: {} });
|
|
172
155
|
addCliToolsToMcp({ cli: tenantCli, server: mcpServer });
|
|
173
156
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
174
|
-
sessionIdGenerator:
|
|
175
|
-
// Pure request/response — no SSE streaming to clean up.
|
|
157
|
+
sessionIdGenerator: undefined,
|
|
176
158
|
enableJsonResponse: true,
|
|
177
|
-
onsessioninitialized: (sid) => {
|
|
178
|
-
transports.set(sid, transport);
|
|
179
|
-
},
|
|
180
|
-
onsessionclosed: (sid) => {
|
|
181
|
-
transports.delete(sid);
|
|
182
|
-
},
|
|
183
159
|
});
|
|
184
|
-
transport.onclose = () => {
|
|
185
|
-
const sid = transport.sessionId;
|
|
186
|
-
if (sid) {
|
|
187
|
-
transports.delete(sid);
|
|
188
|
-
}
|
|
189
|
-
};
|
|
190
160
|
await mcpServer.connect(transport);
|
|
191
|
-
|
|
161
|
+
try {
|
|
162
|
+
return await transport.handleRequest(request, { parsedBody });
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
await transport.close();
|
|
166
|
+
await mcpServer.close();
|
|
167
|
+
}
|
|
192
168
|
};
|
|
193
|
-
return { fetch: customFetch,
|
|
169
|
+
return { fetch: customFetch, sessionHeaders };
|
|
194
170
|
}
|
|
195
171
|
// ─── Tests ────────────────────────────────────────────────────────
|
|
196
172
|
describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () => {
|
|
@@ -207,7 +183,7 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
207
183
|
env: { ROLE: "reader" },
|
|
208
184
|
fs: new InMemoryFs(),
|
|
209
185
|
});
|
|
210
|
-
const { fetch: tenantFetch } = createMultiTenantFetch({
|
|
186
|
+
const { fetch: tenantFetch, sessionHeaders } = createMultiTenantFetch({
|
|
211
187
|
baseCli,
|
|
212
188
|
resolveTenant: (id) => {
|
|
213
189
|
const tenant = tenants.get(id);
|
|
@@ -216,8 +192,6 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
216
192
|
return tenant;
|
|
217
193
|
},
|
|
218
194
|
});
|
|
219
|
-
// The URL is a placeholder — the in-process fetch never looks
|
|
220
|
-
// at the host, just the method/headers/body.
|
|
221
195
|
const endpoint = new URL("http://in-memory-mcp.test/mcp");
|
|
222
196
|
async function connectTenant(tenantId) {
|
|
223
197
|
const transport = new StreamableHTTPClientTransport(endpoint, {
|
|
@@ -232,26 +206,21 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
232
206
|
await client.connect(transport);
|
|
233
207
|
return client;
|
|
234
208
|
}
|
|
235
|
-
return { tenants, connectTenant };
|
|
209
|
+
return { tenants, connectTenant, sessionHeaders };
|
|
236
210
|
}
|
|
237
211
|
function firstTextBlock(result) {
|
|
238
212
|
const content = result.content ?? [];
|
|
239
213
|
return content.find((block) => block.type === "text")?.text ?? "";
|
|
240
214
|
}
|
|
241
|
-
it("routes each
|
|
242
|
-
const { tenants, connectTenant } = setupScenario();
|
|
215
|
+
it("routes each request to its own cli clone with tenant-specific cwd/env/fs", async () => {
|
|
216
|
+
const { tenants, connectTenant, sessionHeaders } = setupScenario();
|
|
243
217
|
const aliceClient = await connectTenant("tenant-a");
|
|
244
218
|
const bobClient = await connectTenant("tenant-b");
|
|
245
219
|
try {
|
|
246
|
-
// Each client sees the same tool catalog — it comes from the
|
|
247
|
-
// shared cli definition.
|
|
248
220
|
const aliceTools = (await aliceClient.listTools()).tools.map((t) => t.name).sort();
|
|
249
221
|
const bobTools = (await bobClient.listTools()).tools.map((t) => t.name).sort();
|
|
250
222
|
expect(aliceTools).toEqual(["load", "save"]);
|
|
251
223
|
expect(bobTools).toEqual(["load", "save"]);
|
|
252
|
-
// Both tenants write a file called `notes.txt` with different
|
|
253
|
-
// content. Since each session uses its own cli clone (with
|
|
254
|
-
// its own cwd + fs), the writes land in separate Maps.
|
|
255
224
|
const aliceSave = await aliceClient.callTool({
|
|
256
225
|
name: "save",
|
|
257
226
|
arguments: { filename: "notes.txt", content: "alice-secret" },
|
|
@@ -264,7 +233,6 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
264
233
|
expect(firstTextBlock(aliceSave)).toContain("tenant-a");
|
|
265
234
|
expect(firstTextBlock(bobSave)).toContain("/workspace-b/notes.txt");
|
|
266
235
|
expect(firstTextBlock(bobSave)).toContain("tenant-b");
|
|
267
|
-
// Each tenant reads back what it wrote.
|
|
268
236
|
const aliceLoad = await aliceClient.callTool({
|
|
269
237
|
name: "load",
|
|
270
238
|
arguments: { filename: "notes.txt" },
|
|
@@ -277,14 +245,13 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
277
245
|
expect(firstTextBlock(aliceLoad)).not.toContain("bob-secret");
|
|
278
246
|
expect(firstTextBlock(bobLoad)).toContain("bob-secret");
|
|
279
247
|
expect(firstTextBlock(bobLoad)).not.toContain("alice-secret");
|
|
280
|
-
// Sanity check: the underlying in-memory maps really are
|
|
281
|
-
// disjoint. Tenant A's fs only has tenant A's file.
|
|
282
248
|
const tenantAFs = tenants.get("tenant-a").fs;
|
|
283
249
|
const tenantBFs = tenants.get("tenant-b").fs;
|
|
284
250
|
expect([...tenantAFs.files.keys()]).toEqual(["/workspace-a/notes.txt"]);
|
|
285
251
|
expect([...tenantBFs.files.keys()]).toEqual(["/workspace-b/notes.txt"]);
|
|
286
252
|
expect(tenantAFs.files.get("/workspace-a/notes.txt")).toBe("alice-secret");
|
|
287
253
|
expect(tenantBFs.files.get("/workspace-b/notes.txt")).toBe("bob-secret");
|
|
254
|
+
expect(sessionHeaders).toEqual([]);
|
|
288
255
|
}
|
|
289
256
|
finally {
|
|
290
257
|
await aliceClient.close();
|
|
@@ -306,4 +273,22 @@ describe("remote MCP over streamable HTTP with multi-tenant in-memory fs", () =>
|
|
|
306
273
|
await bobClient.close();
|
|
307
274
|
}
|
|
308
275
|
});
|
|
276
|
+
it("rejects a present Origin that is not allowed", async () => {
|
|
277
|
+
const { fetch } = createMultiTenantFetch({
|
|
278
|
+
baseCli: buildBaseCli(),
|
|
279
|
+
resolveTenant: () => {
|
|
280
|
+
throw new Error("tenant must not be resolved for a forbidden origin");
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
const response = await fetch("http://in-memory-mcp.test/mcp", {
|
|
284
|
+
method: "POST",
|
|
285
|
+
headers: {
|
|
286
|
+
origin: "https://evil.example",
|
|
287
|
+
"x-tenant-id": "tenant-a",
|
|
288
|
+
"content-type": "application/json",
|
|
289
|
+
},
|
|
290
|
+
body: "{}",
|
|
291
|
+
});
|
|
292
|
+
expect(response.status).toBe(403);
|
|
293
|
+
});
|
|
309
294
|
});
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { OAuthFlowResult, StartOAuthFlowOptions } from "./types.js";
|
|
2
2
|
/**
|
|
3
3
|
* Start the OAuth flow for an MCP server.
|
|
4
|
-
*
|
|
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/cli-to-mcp.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli-to-mcp.d.ts","sourceRoot":"","sources":["../src/cli-to-mcp.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AACxE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+CAA+C,CAAC;AAS/E,OAAO,
|
|
1
|
+
{"version":3,"file":"cli-to-mcp.d.ts","sourceRoot":"","sources":["../src/cli-to-mcp.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AACxE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+CAA+C,CAAC;AAS/E,OAAO,EAML,KAAK,IAAI,EAIV,MAAM,MAAM,CAAC;AAwFd,MAAM,WAAW,uBAAuB;IACtC,GAAG,EAAE,IAAI,CAAC;IACV,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,aAAa,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC;IACjD,gBAAgB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,MAAM,CAAC;CACpD;AA4fD,MAAM,WAAW,sBAAsB;IACrC,mEAAmE;IACnE,GAAG,EAAE,IAAI,CAAC;IACV,iGAAiG;IACjG,aAAa,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC;IACjD,iCAAiC;IACjC,gBAAgB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,MAAM,CAAC;IACnD,oEAAoE;IACpE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8CAA8C;IAC9C,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,iFAAiF;IACjF,eAAe,CAAC,EAAE,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;CACxD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,sBAAsB,GAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAsClG;AAED,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB,GAAG,IAAI,CAyCvE"}
|
package/dist/cli-to-mcp.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* or a high-level McpServer by mounting tools/list + tools/call handlers.
|
|
6
6
|
*/
|
|
7
7
|
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
-
import { coerceBySchema, extractJsonSchema, GokeProcessExit, } from "goke";
|
|
8
|
+
import { coerceBySchema, extractJsonSchema, GokeProcessExit, schemaAcceptsOmittedValue, } from "goke";
|
|
9
9
|
const CLI_TO_MCP_STATE = Symbol.for("@goke/mcp/cli-to-mcp-state");
|
|
10
10
|
function createTextCaptureStream() {
|
|
11
11
|
const chunks = [];
|
|
@@ -359,10 +359,15 @@ function createBinding(cli, command, toolName) {
|
|
|
359
359
|
requiredNames.push(arg.value);
|
|
360
360
|
}
|
|
361
361
|
}
|
|
362
|
+
// `--days <days>` means "value required if the flag is present". The flag
|
|
363
|
+
// itself is required only when a non-optional schema rejects `undefined`.
|
|
362
364
|
for (const option of options) {
|
|
363
365
|
const normalized = normalizeOptionSchema(option);
|
|
364
366
|
properties[option.name] = normalized.schema;
|
|
365
|
-
if (option.required
|
|
367
|
+
if (option.required
|
|
368
|
+
&& option.default === undefined
|
|
369
|
+
&& option.schema
|
|
370
|
+
&& !schemaAcceptsOmittedValue(option.schema)) {
|
|
366
371
|
requiredNames.push(option.name);
|
|
367
372
|
}
|
|
368
373
|
optionBindings.push({
|
package/dist/index.d.ts
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
*
|
|
10
10
|
* - **Auto-discovery**: Fetches all tools from the MCP server and creates CLI commands
|
|
11
11
|
* - **Caching**: Tools are cached for 1 hour to avoid reconnecting on every invocation
|
|
12
|
-
* - **Session reuse**: MCP session IDs are cached to skip initialization handshake
|
|
13
12
|
* - **Type-aware parsing**: Handles string, number, boolean, object, and array arguments
|
|
14
13
|
* - **JSON schema support**: Generates CLI options from tool input schemas
|
|
15
14
|
* - **OAuth support**: Automatic OAuth authentication on 401 errors (lazy auth)
|
|
@@ -41,11 +40,12 @@
|
|
|
41
40
|
*/
|
|
42
41
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
43
42
|
import type { Goke } from "goke";
|
|
43
|
+
export { startOAuthFlow } from "./auth.js";
|
|
44
44
|
import type { McpOAuthConfig } from "./types.js";
|
|
45
45
|
export { addCliToolsToMcp, createMcpAction } from "./cli-to-mcp.js";
|
|
46
46
|
export type { AddCliToolsToMcpOptions, CreateMcpActionOptions } from "./cli-to-mcp.js";
|
|
47
47
|
export type { Transport };
|
|
48
|
-
export type { McpOAuthConfig, McpOAuthState } from "./types.js";
|
|
48
|
+
export type { McpOAuthConfig, McpOAuthState, StartOAuthFlowOptions, OAuthFlowResult } from "./types.js";
|
|
49
49
|
export interface CachedMcpTools {
|
|
50
50
|
tools: Array<{
|
|
51
51
|
name: string;
|
|
@@ -53,7 +53,6 @@ export interface CachedMcpTools {
|
|
|
53
53
|
inputSchema?: unknown;
|
|
54
54
|
}>;
|
|
55
55
|
timestamp: number;
|
|
56
|
-
sessionId?: string;
|
|
57
56
|
}
|
|
58
57
|
export interface AddMcpCommandsOptions {
|
|
59
58
|
cli: Goke;
|
|
@@ -76,9 +75,8 @@ export interface AddMcpCommandsOptions {
|
|
|
76
75
|
/**
|
|
77
76
|
* Returns a transport to connect to the MCP server, or null if not configured.
|
|
78
77
|
* Use this for stdio servers or any setup `getMcpUrl` cannot express.
|
|
79
|
-
* @param sessionId - Optional session ID from a still-valid cache
|
|
80
78
|
*/
|
|
81
|
-
getMcpTransport?: (
|
|
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.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Dynamically generate CLI commands from MCP server tools",
|
|
6
6
|
"repository": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"author": "Tommaso De Rossi, morse <beats.by.morse@gmail.com>",
|
|
41
41
|
"license": "MIT",
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
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.16.0"
|
|
55
55
|
},
|
|
56
56
|
"scripts": {
|
|
57
57
|
"clean": "rm -rf dist",
|
|
@@ -219,11 +219,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
219
219
|
"type": "number",
|
|
220
220
|
"description": "Right operand"
|
|
221
221
|
}
|
|
222
|
-
}
|
|
223
|
-
"required": [
|
|
224
|
-
"left",
|
|
225
|
-
"right"
|
|
226
|
-
]
|
|
222
|
+
}
|
|
227
223
|
}
|
|
228
224
|
},
|
|
229
225
|
{
|
|
@@ -265,10 +261,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
265
261
|
"type": "boolean",
|
|
266
262
|
"description": "Dry run flag"
|
|
267
263
|
}
|
|
268
|
-
}
|
|
269
|
-
"required": [
|
|
270
|
-
"title"
|
|
271
|
-
]
|
|
264
|
+
}
|
|
272
265
|
}
|
|
273
266
|
}
|
|
274
267
|
]"
|
|
@@ -325,11 +318,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
325
318
|
"type": "number",
|
|
326
319
|
"description": "Right operand"
|
|
327
320
|
}
|
|
328
|
-
}
|
|
329
|
-
"required": [
|
|
330
|
-
"left",
|
|
331
|
-
"right"
|
|
332
|
-
]
|
|
321
|
+
}
|
|
333
322
|
}
|
|
334
323
|
},
|
|
335
324
|
{
|
|
@@ -371,10 +360,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
371
360
|
"type": "boolean",
|
|
372
361
|
"description": "Dry run flag"
|
|
373
362
|
}
|
|
374
|
-
}
|
|
375
|
-
"required": [
|
|
376
|
-
"title"
|
|
377
|
-
]
|
|
363
|
+
}
|
|
378
364
|
}
|
|
379
365
|
}
|
|
380
366
|
]"
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// First-run help and stale-cache behavior for addMcpCommands.
|
|
2
2
|
import http from 'node:http'
|
|
3
3
|
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
|
5
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
4
6
|
import { goke } from 'goke'
|
|
7
|
+
import { addCliToolsToMcp } from '../cli-to-mcp.js'
|
|
5
8
|
import { addMcpCommands, type CachedMcpTools } from '../index.js'
|
|
6
9
|
|
|
7
10
|
const staleCache = (over: Partial<CachedMcpTools> = {}): CachedMcpTools => ({
|
|
@@ -13,7 +16,6 @@ const staleCache = (over: Partial<CachedMcpTools> = {}): CachedMcpTools => ({
|
|
|
13
16
|
},
|
|
14
17
|
],
|
|
15
18
|
timestamp: Date.now() - 2 * 60 * 60 * 1000,
|
|
16
|
-
sessionId: 'stale-session',
|
|
17
19
|
...over,
|
|
18
20
|
})
|
|
19
21
|
|
|
@@ -136,4 +138,69 @@ describe('addMcpCommands first-run help', () => {
|
|
|
136
138
|
|
|
137
139
|
expect(transportCalls).toBe(0)
|
|
138
140
|
})
|
|
141
|
+
|
|
142
|
+
it('does not pass a session id into getMcpTransport', async () => {
|
|
143
|
+
const sessionIds: Array<string | undefined> = []
|
|
144
|
+
const cli = goke('testcli')
|
|
145
|
+
|
|
146
|
+
await addMcpCommands({
|
|
147
|
+
cli,
|
|
148
|
+
argv: ['find_bookmarks'],
|
|
149
|
+
getMcpTransport: (sessionId?: string) => {
|
|
150
|
+
sessionIds.push(sessionId)
|
|
151
|
+
return null
|
|
152
|
+
},
|
|
153
|
+
loadCache: () => ({
|
|
154
|
+
tools: [
|
|
155
|
+
{
|
|
156
|
+
name: 'find_bookmarks',
|
|
157
|
+
description: 'Find bookmarks',
|
|
158
|
+
inputSchema: { type: 'object', properties: {} },
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
timestamp: Date.now(),
|
|
162
|
+
sessionId: 'should-not-be-reused',
|
|
163
|
+
}),
|
|
164
|
+
saveCache: () => {},
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const command = cli.commands.find((entry) => entry.name === 'find_bookmarks')
|
|
168
|
+
try {
|
|
169
|
+
await command?.commandAction?.({})
|
|
170
|
+
} catch {
|
|
171
|
+
// transport is null, so the action exits
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
expect(sessionIds).toEqual([undefined])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('caches tools without a session id', async () => {
|
|
178
|
+
const saved: CachedMcpTools[] = []
|
|
179
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
|
180
|
+
const serverCli = goke('server-cli')
|
|
181
|
+
serverCli.command('ping', 'Ping').action(() => 'pong')
|
|
182
|
+
|
|
183
|
+
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: {} })
|
|
184
|
+
addCliToolsToMcp({ cli: serverCli, server })
|
|
185
|
+
await server.connect(serverTransport)
|
|
186
|
+
|
|
187
|
+
const cli = goke('testcli')
|
|
188
|
+
try {
|
|
189
|
+
await addMcpCommands({
|
|
190
|
+
cli,
|
|
191
|
+
argv: ['ping'],
|
|
192
|
+
getMcpTransport: () => clientTransport,
|
|
193
|
+
loadCache: () => undefined,
|
|
194
|
+
saveCache: (cache) => {
|
|
195
|
+
if (cache) saved.push(cache)
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
} finally {
|
|
199
|
+
await server.close()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
expect(saved).toHaveLength(1)
|
|
203
|
+
expect(saved[0]?.tools.map((tool) => tool.name)).toEqual(['ping'])
|
|
204
|
+
expect(saved[0]).not.toHaveProperty('sessionId')
|
|
205
|
+
})
|
|
139
206
|
})
|