@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
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
|
|
|
@@ -129,6 +129,8 @@ my-cli mcp
|
|
|
129
129
|
|
|
130
130
|
When running as MCP, the server exposes `search` and `deploy` as tools. The `mcp` command itself is excluded. Options with Zod schemas (or any Standard Schema) become typed `inputSchema` properties in the MCP tool definition.
|
|
131
131
|
|
|
132
|
+
**`inputSchema.required` is not the CLI `<value>` syntax.** `--query <query>` means the flag needs a value if it is present. The flag is still optional unless the schema rejects omit (`z.string()`, not `z.string().optional()`). Required **positionals** like `<env>` always go in `required`. Schema-required flags go in `required` too. Untyped flags and `wrapJsonSchema()` flags stay optional.
|
|
133
|
+
|
|
132
134
|
### Installing the MCP server in clients
|
|
133
135
|
|
|
134
136
|
Users can install your CLI as an MCP server in any client using [`@playwriter/install-mcp`](https://github.com/nicepkg/install-mcp) — a cross-platform tool that handles config file locations for every major MCP client:
|
|
@@ -194,12 +196,14 @@ addCliToolsToMcp({ cli, server: mcp })
|
|
|
194
196
|
|
|
195
197
|
## Multi-tenant remote MCP over HTTP
|
|
196
198
|
|
|
197
|
-
When you expose a cli as a **remote** MCP
|
|
199
|
+
When you expose a cli as a **remote** MCP over HTTP, one process handles many concurrent users. Each request must run against that user's own filesystem, working directory, environment, and stdin — otherwise tenants see each other's state and stdio writes trample the JSON-RPC channel.
|
|
200
|
+
|
|
201
|
+
Do **not** keep MCP session IDs. Streamable HTTP can run **stateless**: `sessionIdGenerator: undefined`. Each POST builds a fresh `Server` and transport, then closes them. That works on Cloudflare Workers and any other isolate that does not keep process memory.
|
|
198
202
|
|
|
199
203
|
The recipe is:
|
|
200
204
|
|
|
201
205
|
1. Define the cli **once**.
|
|
202
|
-
2. On every
|
|
206
|
+
2. On **every POST**, resolve the tenant from the request (JWT, cookie, header), **clone** the cli with `{ cwd, env, fs }`, and mount it on a fresh `Server` via `addCliToolsToMcp({ cli: tenantClone, server })`.
|
|
203
207
|
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
208
|
|
|
205
209
|
### Write commands against `ctx`
|
|
@@ -231,64 +235,68 @@ cli
|
|
|
231
235
|
|
|
232
236
|
`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
237
|
|
|
234
|
-
### Clone the cli per
|
|
238
|
+
### Clone the cli per request
|
|
235
239
|
|
|
236
240
|
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
241
|
|
|
242
|
+
Pass `sessionIdGenerator: undefined` so the transport does not emit or expect `mcp-session-id`. Do not store transports in a `Map`. JSON-RPC `initialize`, `tools/list`, and `tools/call` are separate POSTs. Each one clones the cli from the request's tenant identity.
|
|
243
|
+
|
|
244
|
+
This is MCP SDK v1 Streamable HTTP **without optional transport sessions** (protocol revisions through `2025-11-25`). It is not the later `2026-07-28` protocol, which drops `initialize`. Session IDs are optional in `2025-11-25`. Stateless servers omit them.
|
|
245
|
+
|
|
246
|
+
The Streamable HTTP spec requires **Origin** checks on every request (DNS rebinding). Reject a present, disallowed `Origin` with **403**. Missing `Origin` is normal for non-browser MCP clients.
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
POST /mcp (JWT / x-tenant-id)
|
|
250
|
+
│
|
|
251
|
+
v
|
|
252
|
+
resolveTenant(id) ──> baseCli.clone({ cwd, env, fs })
|
|
253
|
+
│
|
|
254
|
+
v
|
|
255
|
+
fresh Server + transport
|
|
256
|
+
sessionIdGenerator: undefined
|
|
257
|
+
│
|
|
258
|
+
v
|
|
259
|
+
addCliToolsToMcp ──> handleRequest
|
|
260
|
+
│
|
|
261
|
+
v
|
|
262
|
+
Response
|
|
263
|
+
│
|
|
264
|
+
v
|
|
265
|
+
close transport + server ──> nothing stored
|
|
266
|
+
```
|
|
267
|
+
|
|
238
268
|
You build one `handleMcpRequest(request: Request): Promise<Response>` function and mount it wherever you route HTTP:
|
|
239
269
|
|
|
240
270
|
```ts
|
|
241
|
-
import { randomUUID } from "node:crypto"
|
|
242
271
|
import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js"
|
|
243
272
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
|
|
244
|
-
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"
|
|
245
273
|
import { addCliToolsToMcp } from "@goke/mcp"
|
|
246
274
|
import type { GokeFs } from "goke"
|
|
247
275
|
|
|
248
|
-
// Wherever you store per-user state — DB, Redis, config files, etc.
|
|
249
276
|
declare function resolveTenant(tenantId: string): {
|
|
250
277
|
cwd: string
|
|
251
278
|
env: Record<string, string>
|
|
252
|
-
fs: GokeFs
|
|
279
|
+
fs: GokeFs
|
|
253
280
|
}
|
|
254
281
|
|
|
255
|
-
|
|
282
|
+
declare function isAllowedOrigin(origin: string): boolean
|
|
256
283
|
|
|
257
284
|
export async function handleMcpRequest(request: Request): Promise<Response> {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
let parsedBody: unknown
|
|
262
|
-
if (request.method === "POST") {
|
|
263
|
-
parsedBody = await request.clone().json().catch(() => undefined)
|
|
285
|
+
const origin = request.headers.get("origin")
|
|
286
|
+
if (origin && !isAllowedOrigin(origin)) {
|
|
287
|
+
return new Response(null, { status: 403 })
|
|
264
288
|
}
|
|
265
289
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
// Existing session — route to its transport.
|
|
269
|
-
if (sessionId && transports.has(sessionId)) {
|
|
270
|
-
return transports.get(sessionId)!.handleRequest(request, { parsedBody })
|
|
290
|
+
if (request.method !== "POST") {
|
|
291
|
+
return new Response(null, { status: 405, headers: { Allow: "POST" } })
|
|
271
292
|
}
|
|
272
293
|
|
|
273
|
-
|
|
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
|
-
}
|
|
294
|
+
const parsedBody = await request.clone().json().catch(() => undefined)
|
|
284
295
|
|
|
285
|
-
// Derive the tenant from whatever header/cookie/JWT you use.
|
|
286
296
|
const tenantId = request.headers.get("x-tenant-id")
|
|
287
297
|
if (!tenantId) return new Response("missing x-tenant-id", { status: 401 })
|
|
288
298
|
const tenant = resolveTenant(tenantId)
|
|
289
299
|
|
|
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
300
|
const tenantCli = baseCli.clone({
|
|
293
301
|
cwd: tenant.cwd,
|
|
294
302
|
env: { ...tenant.env, TENANT_ID: tenantId },
|
|
@@ -302,22 +310,16 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
|
|
302
310
|
addCliToolsToMcp({ cli: tenantCli, server: mcpServer })
|
|
303
311
|
|
|
304
312
|
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
|
-
},
|
|
313
|
+
sessionIdGenerator: undefined,
|
|
314
|
+
enableJsonResponse: true,
|
|
313
315
|
})
|
|
314
|
-
transport.onclose = () => {
|
|
315
|
-
const sid = transport.sessionId
|
|
316
|
-
if (sid) transports.delete(sid)
|
|
317
|
-
}
|
|
318
|
-
|
|
319
316
|
await mcpServer.connect(transport)
|
|
320
|
-
|
|
317
|
+
try {
|
|
318
|
+
return await transport.handleRequest(request, { parsedBody })
|
|
319
|
+
} finally {
|
|
320
|
+
await transport.close()
|
|
321
|
+
await mcpServer.close()
|
|
322
|
+
}
|
|
321
323
|
}
|
|
322
324
|
```
|
|
323
325
|
|
|
@@ -353,7 +355,7 @@ export async function POST(request: Request) {
|
|
|
353
355
|
|
|
354
356
|
**Key guarantees**
|
|
355
357
|
|
|
356
|
-
- Every
|
|
358
|
+
- Every request runs against `tenantCli`'s `cwd` / `env` / `fs` — not the base cli's and not another tenant's.
|
|
357
359
|
- `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
360
|
- `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
361
|
- Actions that `throw` are caught and returned as `{ isError: true, content: [message, stderr] }`.
|
|
@@ -369,7 +371,7 @@ Only code that flows through `ctx` participates in the isolation. The following
|
|
|
369
371
|
|
|
370
372
|
Port those to `ctx.fs`, `ctx.console`, `ctx.process.*` and you're multi-tenant-safe.
|
|
371
373
|
|
|
372
|
-
For a runnable end-to-end example (including two concurrent in-memory-fs tenants writing separate files), see [`src/__test__/http-multi-tenant.test.ts`](./src/__test__/http-multi-tenant.test.ts).
|
|
374
|
+
For a runnable end-to-end example (including two concurrent in-memory-fs tenants writing separate files, with a new server per POST), see [`src/__test__/http-multi-tenant.test.ts`](./src/__test__/http-multi-tenant.test.ts).
|
|
373
375
|
|
|
374
376
|
### Lower-level primitive: `cli.createExecutionContext(override)`
|
|
375
377
|
|
|
@@ -486,7 +488,7 @@ Registers MCP tool commands on a goke CLI instance.
|
|
|
486
488
|
|--------|------|---------|-------------|
|
|
487
489
|
| `cli` | `Goke` | **required** | The goke CLI instance to add commands to |
|
|
488
490
|
| `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` | `(
|
|
491
|
+
| `getMcpTransport` | `() => Transport \| null` | — | Custom transport. Use for stdio or anything `getMcpUrl` cannot express |
|
|
490
492
|
| `getHeaders` | `() => Record<string, string> \| undefined` | — | Extra HTTP headers (for example `Authorization`). Used with `getMcpUrl` |
|
|
491
493
|
| `argv` | `string[]` | `process.argv.slice(2)` | Args used to skip live discovery on help and already registered commands |
|
|
492
494
|
| `commandPrefix` | `string` | `''` | Prefix for commands (e.g. `'mcp'` makes `mcp notion-search`) |
|
|
@@ -554,7 +556,7 @@ Tokens are persisted via the `oauth.save()` callback you provide, so subsequent
|
|
|
554
556
|
|
|
555
557
|
## Caching
|
|
556
558
|
|
|
557
|
-
|
|
559
|
+
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
560
|
|
|
559
561
|
When the cache expires or a tool call fails, the cache is cleared and tools are re-fetched on the next run.
|
|
560
562
|
|
|
@@ -178,11 +178,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
178
178
|
"type": "number",
|
|
179
179
|
"description": "Right operand"
|
|
180
180
|
}
|
|
181
|
-
}
|
|
182
|
-
"required": [
|
|
183
|
-
"left",
|
|
184
|
-
"right"
|
|
185
|
-
]
|
|
181
|
+
}
|
|
186
182
|
}
|
|
187
183
|
},
|
|
188
184
|
{
|
|
@@ -224,10 +220,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
224
220
|
"type": "boolean",
|
|
225
221
|
"description": "Dry run flag"
|
|
226
222
|
}
|
|
227
|
-
}
|
|
228
|
-
"required": [
|
|
229
|
-
"title"
|
|
230
|
-
]
|
|
223
|
+
}
|
|
231
224
|
}
|
|
232
225
|
}
|
|
233
226
|
]"
|
|
@@ -280,11 +273,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
280
273
|
"type": "number",
|
|
281
274
|
"description": "Right operand"
|
|
282
275
|
}
|
|
283
|
-
}
|
|
284
|
-
"required": [
|
|
285
|
-
"left",
|
|
286
|
-
"right"
|
|
287
|
-
]
|
|
276
|
+
}
|
|
288
277
|
}
|
|
289
278
|
},
|
|
290
279
|
{
|
|
@@ -326,10 +315,7 @@ describe("addCliToolsToMcp", () => {
|
|
|
326
315
|
"type": "boolean",
|
|
327
316
|
"description": "Dry run flag"
|
|
328
317
|
}
|
|
329
|
-
}
|
|
330
|
-
"required": [
|
|
331
|
-
"title"
|
|
332
|
-
]
|
|
318
|
+
}
|
|
333
319
|
}
|
|
334
320
|
}
|
|
335
321
|
]"
|
|
@@ -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
|
});
|
|
@@ -229,6 +229,120 @@ describe("createMcpAction", () => {
|
|
|
229
229
|
await client.close();
|
|
230
230
|
}
|
|
231
231
|
});
|
|
232
|
+
it("keeps --flag <value> in properties but not in required", async () => {
|
|
233
|
+
const cli = goke("strada");
|
|
234
|
+
cli
|
|
235
|
+
.command("projects create <slug>", "Create a project")
|
|
236
|
+
.option("--traces-days <days>", z.string().optional().describe("Traces retention days"))
|
|
237
|
+
.option("--logs-days <days>", z.string().optional().describe("Logs retention days"))
|
|
238
|
+
.option("--errors-days <days>", z.string().optional().describe("Errors retention days"))
|
|
239
|
+
.option("--metrics-days <days>", z.string().optional().describe("Metrics retention days"))
|
|
240
|
+
.option("--all-days <days>", z.string().optional().describe("Retention for all signals"))
|
|
241
|
+
.action((slug, options) => ({ slug, ...options }));
|
|
242
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
243
|
+
cli.command("mcp", "Start MCP server").action(createMcpAction({
|
|
244
|
+
cli,
|
|
245
|
+
createTransport: () => serverTransport,
|
|
246
|
+
}));
|
|
247
|
+
cli.matchedCommandName = "mcp";
|
|
248
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp");
|
|
249
|
+
await mcpCommand.commandAction({});
|
|
250
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
251
|
+
await client.connect(clientTransport);
|
|
252
|
+
try {
|
|
253
|
+
const tools = await client.listTools();
|
|
254
|
+
const createTool = tools.tools.find((t) => t.name === "projects_create");
|
|
255
|
+
expect(createTool.inputSchema.properties).toHaveProperty("slug");
|
|
256
|
+
expect(createTool.inputSchema.properties).toHaveProperty("tracesDays");
|
|
257
|
+
expect(createTool.inputSchema.properties).toHaveProperty("logsDays");
|
|
258
|
+
expect(createTool.inputSchema.properties).toHaveProperty("allDays");
|
|
259
|
+
expect(createTool.inputSchema.required).toEqual(["slug"]);
|
|
260
|
+
const result = await client.callTool({
|
|
261
|
+
name: "projects_create",
|
|
262
|
+
arguments: { slug: "my-app" },
|
|
263
|
+
});
|
|
264
|
+
expect(firstTextContent(result)).toBe('{\n "slug": "my-app"\n}');
|
|
265
|
+
await expect(client.callTool({
|
|
266
|
+
name: "projects_create",
|
|
267
|
+
arguments: {},
|
|
268
|
+
})).rejects.toThrow("Missing required argument: slug");
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
await client.close();
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
it("puts schema-required --flag <value> in inputSchema.required", async () => {
|
|
275
|
+
const cli = goke("checks");
|
|
276
|
+
cli
|
|
277
|
+
.command("checks create", "Create a check")
|
|
278
|
+
.option("--url <url>", z.string().describe("URL to check"))
|
|
279
|
+
.option("--name <name>", z.string().describe("Check name"))
|
|
280
|
+
.option("--timeout [ms]", z.number().optional().describe("Timeout"))
|
|
281
|
+
.action((options) => options);
|
|
282
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
283
|
+
cli.command("mcp", "Start MCP server").action(createMcpAction({
|
|
284
|
+
cli,
|
|
285
|
+
createTransport: () => serverTransport,
|
|
286
|
+
}));
|
|
287
|
+
cli.matchedCommandName = "mcp";
|
|
288
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp");
|
|
289
|
+
await mcpCommand.commandAction({});
|
|
290
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
291
|
+
await client.connect(clientTransport);
|
|
292
|
+
try {
|
|
293
|
+
const tools = await client.listTools();
|
|
294
|
+
const createTool = tools.tools.find((t) => t.name === "checks_create");
|
|
295
|
+
expect(createTool.inputSchema.properties).toHaveProperty("url");
|
|
296
|
+
expect(createTool.inputSchema.properties).toHaveProperty("name");
|
|
297
|
+
expect(createTool.inputSchema.properties).toHaveProperty("timeout");
|
|
298
|
+
expect(createTool.inputSchema.required).toEqual(["url", "name"]);
|
|
299
|
+
const result = await client.callTool({
|
|
300
|
+
name: "checks_create",
|
|
301
|
+
arguments: { url: "https://example.com", name: "home" },
|
|
302
|
+
});
|
|
303
|
+
expect(firstTextContent(result)).toContain("https://example.com");
|
|
304
|
+
await expect(client.callTool({
|
|
305
|
+
name: "checks_create",
|
|
306
|
+
arguments: { name: "home" },
|
|
307
|
+
})).rejects.toThrow("Missing required argument: url");
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
await client.close();
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
it("does not put wrapJsonSchema flags in required", async () => {
|
|
314
|
+
const cli = goke("wrapped");
|
|
315
|
+
cli
|
|
316
|
+
.command("config set", "Set a config value")
|
|
317
|
+
.option("--key <key>", wrapJsonSchema({ type: "string", description: "Config key" }))
|
|
318
|
+
.option("--value <value>", wrapJsonSchema({ type: "string", description: "Config value" }))
|
|
319
|
+
.action((options) => options);
|
|
320
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
321
|
+
cli.command("mcp", "Start MCP server").action(createMcpAction({
|
|
322
|
+
cli,
|
|
323
|
+
createTransport: () => serverTransport,
|
|
324
|
+
}));
|
|
325
|
+
cli.matchedCommandName = "mcp";
|
|
326
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp");
|
|
327
|
+
await mcpCommand.commandAction({});
|
|
328
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
329
|
+
await client.connect(clientTransport);
|
|
330
|
+
try {
|
|
331
|
+
const tools = await client.listTools();
|
|
332
|
+
const setTool = tools.tools.find((t) => t.name === "config_set");
|
|
333
|
+
expect(setTool.inputSchema.properties).toHaveProperty("key");
|
|
334
|
+
expect(setTool.inputSchema.properties).toHaveProperty("value");
|
|
335
|
+
expect(setTool.inputSchema.required).toBeUndefined();
|
|
336
|
+
const result = await client.callTool({
|
|
337
|
+
name: "config_set",
|
|
338
|
+
arguments: {},
|
|
339
|
+
});
|
|
340
|
+
expect(firstTextContent(result)).toBe("{}");
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
await client.close();
|
|
344
|
+
}
|
|
345
|
+
});
|
|
232
346
|
it("returns empty tool list when only the mcp command exists", async () => {
|
|
233
347
|
const cli = goke("empty-app");
|
|
234
348
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
@@ -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,11 +15,15 @@
|
|
|
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
|
|
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG"}
|