@goke/mcp 0.0.8 → 0.0.10
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 +295 -29
- package/dist/__test__/add-cli-tools-to-mcp.test.js +223 -2
- package/dist/__test__/create-mcp-action.test.d.ts +8 -0
- package/dist/__test__/create-mcp-action.test.d.ts.map +1 -0
- package/dist/__test__/create-mcp-action.test.js +276 -0
- package/dist/__test__/http-multi-tenant.test.d.ts +28 -0
- package/dist/__test__/http-multi-tenant.test.d.ts.map +1 -0
- package/dist/__test__/http-multi-tenant.test.js +309 -0
- package/dist/cli-to-mcp.d.ts +28 -0
- package/dist/cli-to-mcp.d.ts.map +1 -1
- package/dist/cli-to-mcp.js +162 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/src/__test__/add-cli-tools-to-mcp.test.ts +306 -7
- package/src/__test__/create-mcp-action.test.ts +355 -0
- package/src/__test__/http-multi-tenant.test.ts +383 -0
- package/src/cli-to-mcp.ts +236 -6
- package/src/index.ts +2 -2
package/dist/cli-to-mcp.js
CHANGED
|
@@ -5,8 +5,19 @@
|
|
|
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 } from "goke";
|
|
8
|
+
import { coerceBySchema, extractJsonSchema, GokeProcessExit, } from "goke";
|
|
9
9
|
const CLI_TO_MCP_STATE = Symbol.for("@goke/mcp/cli-to-mcp-state");
|
|
10
|
+
function createTextCaptureStream() {
|
|
11
|
+
const chunks = [];
|
|
12
|
+
return {
|
|
13
|
+
get text() {
|
|
14
|
+
return chunks.join("");
|
|
15
|
+
},
|
|
16
|
+
write(data) {
|
|
17
|
+
chunks.push(data);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
10
21
|
function isMountableCommand(command, commandFilter) {
|
|
11
22
|
if (!command.commandAction) {
|
|
12
23
|
return false;
|
|
@@ -167,6 +178,90 @@ function isToolNotFoundResult(result, toolName) {
|
|
|
167
178
|
const text = String(textBlock?.text ?? "").toLowerCase();
|
|
168
179
|
return text.includes("tool") && text.includes("not found") && text.includes(toolName.toLowerCase());
|
|
169
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Build the same `GokeExecutionContext` an action would receive from
|
|
183
|
+
* `cli.parse()`, but with capture streams for stdout/stderr and an
|
|
184
|
+
* `exit` that throws `GokeProcessExit` instead of killing the host
|
|
185
|
+
* process.
|
|
186
|
+
*
|
|
187
|
+
* Capturing is required for the stdio MCP transport because the host
|
|
188
|
+
* `process.stdout` is the JSON-RPC channel — any write to it would
|
|
189
|
+
* corrupt the protocol. Capturing is also what lets us surface
|
|
190
|
+
* `ctx.console.log` output in the `CallToolResult.content`.
|
|
191
|
+
*/
|
|
192
|
+
function createCallToolExecutionContext(cli) {
|
|
193
|
+
const stdout = createTextCaptureStream();
|
|
194
|
+
const stderr = createTextCaptureStream();
|
|
195
|
+
const ctx = cli.createExecutionContext({
|
|
196
|
+
stdout,
|
|
197
|
+
stderr,
|
|
198
|
+
// Swallow the user-level exit: the outer createExecutionContext
|
|
199
|
+
// wrapper will still throw `GokeProcessExit` after this returns,
|
|
200
|
+
// which `runCliTool` catches and turns into a `CallToolResult`.
|
|
201
|
+
exit: () => { },
|
|
202
|
+
});
|
|
203
|
+
return { ctx, stdout, stderr };
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Build a `CallToolResult` from an action's return value plus any
|
|
207
|
+
* text that was captured from the injected `ctx.console` /
|
|
208
|
+
* `ctx.process.stdout` / `ctx.process.stderr` streams.
|
|
209
|
+
*
|
|
210
|
+
* Precedence rules:
|
|
211
|
+
* 1. If the action returned a ready-made `CallToolResult` (object
|
|
212
|
+
* with a `content` key), honor it as-is. Captured output is
|
|
213
|
+
* ignored to give authors a fully manual escape hatch.
|
|
214
|
+
* 2. If anything was captured on stdout or stderr, emit one text
|
|
215
|
+
* block per non-empty stream (stdout first, then stderr) and
|
|
216
|
+
* append the stringified return value as a trailing block when
|
|
217
|
+
* it is non-empty. This keeps warnings written via
|
|
218
|
+
* `ctx.console.error` / `ctx.process.stderr.write` from being
|
|
219
|
+
* silently dropped when the action also returns a value.
|
|
220
|
+
* 3. Otherwise fall back to the legacy behavior (stringify the
|
|
221
|
+
* return value, empty string when `undefined`).
|
|
222
|
+
*/
|
|
223
|
+
function buildCallToolResult(returnValue, capturedStdout, capturedStderr) {
|
|
224
|
+
if (returnValue && typeof returnValue === "object" && "content" in returnValue) {
|
|
225
|
+
return returnValue;
|
|
226
|
+
}
|
|
227
|
+
if (capturedStdout || capturedStderr) {
|
|
228
|
+
const blocks = [];
|
|
229
|
+
if (capturedStdout) {
|
|
230
|
+
blocks.push({ type: "text", text: capturedStdout });
|
|
231
|
+
}
|
|
232
|
+
if (capturedStderr) {
|
|
233
|
+
blocks.push({ type: "text", text: capturedStderr });
|
|
234
|
+
}
|
|
235
|
+
const valueText = formatTextResult(returnValue);
|
|
236
|
+
if (valueText) {
|
|
237
|
+
blocks.push({ type: "text", text: valueText });
|
|
238
|
+
}
|
|
239
|
+
return { content: blocks };
|
|
240
|
+
}
|
|
241
|
+
return toCallToolResult(returnValue);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Build an error `CallToolResult` from captured output + the process
|
|
245
|
+
* exit code thrown by `ctx.process.exit(code)`. Mirrors the
|
|
246
|
+
* `{ stdout, stderr, exitCode }` shape just-bash produces, but in the
|
|
247
|
+
* MCP content-block format.
|
|
248
|
+
*/
|
|
249
|
+
function buildProcessExitResult(exitCode, capturedStdout, capturedStderr) {
|
|
250
|
+
const content = [];
|
|
251
|
+
if (capturedStdout) {
|
|
252
|
+
content.push({ type: "text", text: capturedStdout });
|
|
253
|
+
}
|
|
254
|
+
if (capturedStderr) {
|
|
255
|
+
content.push({ type: "text", text: capturedStderr });
|
|
256
|
+
}
|
|
257
|
+
if (content.length === 0) {
|
|
258
|
+
content.push({ type: "text", text: `Process exited with code ${exitCode}` });
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
isError: exitCode !== 0,
|
|
262
|
+
content,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
170
265
|
async function runCliTool(binding, argumentsObject) {
|
|
171
266
|
for (const requiredName of binding.requiredNames) {
|
|
172
267
|
if (getToolCallArguments(argumentsObject, requiredName) === undefined) {
|
|
@@ -212,19 +307,35 @@ async function runCliTool(binding, argumentsObject) {
|
|
|
212
307
|
if (!action) {
|
|
213
308
|
throw new McpError(ErrorCode.InvalidParams, `Command ${binding.command.name} has no action`);
|
|
214
309
|
}
|
|
310
|
+
// Build the same execution context an action would see when invoked
|
|
311
|
+
// from the command line, but with capture streams + a no-op `exit`
|
|
312
|
+
// so tool calls can't corrupt the MCP transport or kill the host.
|
|
313
|
+
const { ctx, stdout, stderr } = createCallToolExecutionContext(binding.cli);
|
|
215
314
|
try {
|
|
216
|
-
|
|
217
|
-
|
|
315
|
+
// Match `Goke#runMatchedCommand` by calling the action with the
|
|
316
|
+
// owning cli as `this`. Keeps behavior parity for JS authors who
|
|
317
|
+
// reference `this.name` / `this.options` from inside an action.
|
|
318
|
+
const result = await Promise.resolve(action.apply(binding.cli, [...positionalValues, optionsObject, ctx]));
|
|
319
|
+
return buildCallToolResult(result, stdout.text, stderr.text);
|
|
218
320
|
}
|
|
219
321
|
catch (error) {
|
|
322
|
+
if (error instanceof GokeProcessExit) {
|
|
323
|
+
return buildProcessExitResult(error.code, stdout.text, stderr.text);
|
|
324
|
+
}
|
|
220
325
|
const message = error instanceof Error ? error.message : String(error);
|
|
326
|
+
const content = [
|
|
327
|
+
{ type: "text", text: message },
|
|
328
|
+
];
|
|
329
|
+
if (stderr.text) {
|
|
330
|
+
content.push({ type: "text", text: stderr.text });
|
|
331
|
+
}
|
|
221
332
|
return {
|
|
222
333
|
isError: true,
|
|
223
|
-
content
|
|
334
|
+
content,
|
|
224
335
|
};
|
|
225
336
|
}
|
|
226
337
|
}
|
|
227
|
-
function createBinding(command, toolName) {
|
|
338
|
+
function createBinding(cli, command, toolName) {
|
|
228
339
|
const positionalArgs = command.args;
|
|
229
340
|
const options = command.options;
|
|
230
341
|
const properties = {};
|
|
@@ -272,6 +383,7 @@ function createBinding(command, toolName) {
|
|
|
272
383
|
inputSchema,
|
|
273
384
|
},
|
|
274
385
|
command,
|
|
386
|
+
cli,
|
|
275
387
|
positionalArgs,
|
|
276
388
|
options: optionBindings,
|
|
277
389
|
requiredNames: Array.from(new Set(requiredNames)),
|
|
@@ -342,6 +454,50 @@ function getOrInstallState(server) {
|
|
|
342
454
|
});
|
|
343
455
|
return state;
|
|
344
456
|
}
|
|
457
|
+
/**
|
|
458
|
+
* Create a goke action callback that starts an MCP server over stdio.
|
|
459
|
+
*
|
|
460
|
+
* Exposes all CLI commands as MCP tools, automatically excluding the
|
|
461
|
+
* command this action is attached to.
|
|
462
|
+
*
|
|
463
|
+
* @example
|
|
464
|
+
* ```ts
|
|
465
|
+
* cli.command('mcp', 'Start MCP server over stdio')
|
|
466
|
+
* .action(createMcpAction({ cli }))
|
|
467
|
+
* ```
|
|
468
|
+
*/
|
|
469
|
+
export function createMcpAction(options) {
|
|
470
|
+
const { cli, commandFilter: userFilter, sanitizeToolName, serverName, serverVersion, createTransport } = options;
|
|
471
|
+
return async () => {
|
|
472
|
+
// At call time, goke has already matched the command and set matchedCommandName.
|
|
473
|
+
// We use it to auto-exclude the MCP command itself from the tool list.
|
|
474
|
+
const mcpCommandName = cli.matchedCommandName;
|
|
475
|
+
const { Server: ServerClass } = await import("@modelcontextprotocol/sdk/server/index.js");
|
|
476
|
+
const server = new ServerClass({
|
|
477
|
+
name: serverName || cli.name || "cli-mcp-server",
|
|
478
|
+
version: serverVersion || "1.0.0",
|
|
479
|
+
}, { capabilities: {} });
|
|
480
|
+
addCliToolsToMcp({
|
|
481
|
+
cli,
|
|
482
|
+
server,
|
|
483
|
+
commandFilter: (name) => {
|
|
484
|
+
if (mcpCommandName && name === mcpCommandName)
|
|
485
|
+
return false;
|
|
486
|
+
return userFilter ? userFilter(name) : true;
|
|
487
|
+
},
|
|
488
|
+
sanitizeToolName,
|
|
489
|
+
});
|
|
490
|
+
let transport;
|
|
491
|
+
if (createTransport) {
|
|
492
|
+
transport = await createTransport();
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
496
|
+
transport = new StdioServerTransport();
|
|
497
|
+
}
|
|
498
|
+
await server.connect(transport);
|
|
499
|
+
};
|
|
500
|
+
}
|
|
345
501
|
export function addCliToolsToMcp(options) {
|
|
346
502
|
const { cli, commandFilter, sanitizeToolName = defaultSanitizeToolName } = options;
|
|
347
503
|
const server = resolveServer(options.server);
|
|
@@ -373,7 +529,7 @@ export function addCliToolsToMcp(options) {
|
|
|
373
529
|
const baseToolName = defaultSanitizeToolName(sanitizeToolName(command.name));
|
|
374
530
|
const toolName = uniqueToolName(baseToolName, usedNames);
|
|
375
531
|
usedNames.add(toolName);
|
|
376
|
-
const binding = createBinding(command, toolName);
|
|
532
|
+
const binding = createBinding(cli, command, toolName);
|
|
377
533
|
state.toolsByName.set(toolName, binding);
|
|
378
534
|
state.commandToToolName.set(command.name, toolName);
|
|
379
535
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
43
43
|
import type { Goke } from "goke";
|
|
44
44
|
import type { McpOAuthConfig } from "./types.js";
|
|
45
|
-
export { addCliToolsToMcp } from "./cli-to-mcp.js";
|
|
46
|
-
export type { AddCliToolsToMcpOptions } from "./cli-to-mcp.js";
|
|
45
|
+
export { addCliToolsToMcp, createMcpAction } from "./cli-to-mcp.js";
|
|
46
|
+
export type { AddCliToolsToMcpOptions, CreateMcpActionOptions } from "./cli-to-mcp.js";
|
|
47
47
|
export type { Transport };
|
|
48
48
|
export type { McpOAuthConfig, McpOAuthState } from "./types.js";
|
|
49
49
|
export interface CachedMcpTools {
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+CAA+C,CAAC;AAC/E,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAKjC,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,YAAY,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+CAA+C,CAAC;AAC/E,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAKjC,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,YAAY,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACpE,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAC;AAGvF,YAAY,EAAE,SAAS,EAAE,CAAC;AAC1B,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhE,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,KAAK,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,WAAW,CAAC,EAAE,OAAO,CAAC;KACvB,CAAC,CAAC;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAID,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,IAAI,CAAC;IACV;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAErC;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,SAAS,CAAC,EAAE,MAAM,KAAK,SAAS,GAAG,IAAI,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC;IAEvF;;;;;;;;;;;;;;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;AAmID;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyMlF"}
|
package/dist/index.js
CHANGED
|
@@ -45,7 +45,7 @@ import { wrapJsonSchema } from "goke";
|
|
|
45
45
|
import yaml from "js-yaml";
|
|
46
46
|
import { FileOAuthProvider } from "./oauth-provider.js";
|
|
47
47
|
import { startOAuthFlow, isAuthRequiredError } from "./auth.js";
|
|
48
|
-
export { addCliToolsToMcp } from "./cli-to-mcp.js";
|
|
48
|
+
export { addCliToolsToMcp, createMcpAction } from "./cli-to-mcp.js";
|
|
49
49
|
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
50
50
|
/**
|
|
51
51
|
* Check if a schema represents a complex type (object/array) for help text display.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goke/mcp",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Dynamically generate CLI commands from MCP server tools",
|
|
6
6
|
"repository": {
|
|
@@ -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.8.0"
|
|
55
55
|
},
|
|
56
56
|
"scripts": {
|
|
57
57
|
"clean": "rm -rf dist",
|
|
@@ -7,7 +7,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
|
7
7
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
8
8
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
9
|
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
-
import { goke, wrapJsonSchema } from "goke";
|
|
10
|
+
import { goke, wrapJsonSchema, type Goke } from "goke";
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
import { describe, expect, it } from "vitest";
|
|
13
13
|
import { addCliToolsToMcp } from "../cli-to-mcp.js";
|
|
@@ -19,11 +19,13 @@ function createCli() {
|
|
|
19
19
|
.command("say hi", "Say hello")
|
|
20
20
|
.option("--name <name>", z.string().describe("Person to greet"))
|
|
21
21
|
.option("--caps", z.boolean().default(false).describe("Uppercase output"))
|
|
22
|
-
.action((options
|
|
22
|
+
.action((options) => {
|
|
23
23
|
const message = `Hello ${options.name}!`;
|
|
24
24
|
return options.caps ? message.toUpperCase() : message;
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
+
// sum-values uses wrapJsonSchema whose output is `unknown`, so values are
|
|
28
|
+
// cast with Number() inside the action.
|
|
27
29
|
cli
|
|
28
30
|
.command("sum-values", "Add two numbers")
|
|
29
31
|
.option(
|
|
@@ -40,8 +42,8 @@ function createCli() {
|
|
|
40
42
|
description: "Right operand",
|
|
41
43
|
}),
|
|
42
44
|
)
|
|
43
|
-
.action((options
|
|
44
|
-
sum: options.left + options.right,
|
|
45
|
+
.action((options) => ({
|
|
46
|
+
sum: Number(options.left) + Number(options.right),
|
|
45
47
|
}));
|
|
46
48
|
|
|
47
49
|
cli
|
|
@@ -54,8 +56,8 @@ function createCli() {
|
|
|
54
56
|
description: "Repeat count",
|
|
55
57
|
}),
|
|
56
58
|
)
|
|
57
|
-
.action((message
|
|
58
|
-
return message.repeat(options.repeat);
|
|
59
|
+
.action((message, options) => {
|
|
60
|
+
return message.repeat(Number(options.repeat));
|
|
59
61
|
});
|
|
60
62
|
|
|
61
63
|
cli
|
|
@@ -63,7 +65,7 @@ function createCli() {
|
|
|
63
65
|
.option("--title <title>", "Required title")
|
|
64
66
|
.option("--tag [tag]", "Optional tag")
|
|
65
67
|
.option("--dry-run", "Dry run flag")
|
|
66
|
-
.action((options
|
|
68
|
+
.action((options) => {
|
|
67
69
|
return options;
|
|
68
70
|
});
|
|
69
71
|
|
|
@@ -457,3 +459,300 @@ describe("addCliToolsToMcp", () => {
|
|
|
457
459
|
}
|
|
458
460
|
});
|
|
459
461
|
});
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Spin up a live MCP client/server pair wired to a single cli.
|
|
465
|
+
*
|
|
466
|
+
* Used by the execution-context tests below to keep the boilerplate
|
|
467
|
+
* out of each test body.
|
|
468
|
+
*/
|
|
469
|
+
async function withMcpClient<T>(
|
|
470
|
+
cli: Goke,
|
|
471
|
+
fn: (client: Client) => Promise<T>,
|
|
472
|
+
): Promise<T> {
|
|
473
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
474
|
+
const server = new Server({ name: "test-server", version: "1.0.0" }, { capabilities: {} });
|
|
475
|
+
addCliToolsToMcp({ cli, server });
|
|
476
|
+
|
|
477
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
478
|
+
try {
|
|
479
|
+
await server.connect(serverTransport);
|
|
480
|
+
await client.connect(clientTransport);
|
|
481
|
+
return await fn(client);
|
|
482
|
+
} finally {
|
|
483
|
+
await client.close();
|
|
484
|
+
await server.close();
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function textBlocks(result: Awaited<ReturnType<Client["callTool"]>>): string[] {
|
|
489
|
+
const content = "content" in result ? (result as { content: Array<{ type: string; text?: string }> }).content : [];
|
|
490
|
+
return content.filter((entry) => entry.type === "text").map((entry) => entry.text ?? "");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
describe("addCliToolsToMcp execution context", () => {
|
|
494
|
+
it("passes an execution context as the third argument to the action", async () => {
|
|
495
|
+
const cli = goke("ctx-cli", {
|
|
496
|
+
cwd: "/workspace",
|
|
497
|
+
env: { TOKEN: "abc", USER: "tommy" },
|
|
498
|
+
stdin: "hello from stdin",
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
cli.command("inspect-ctx", "Return the injected execution context").action((_options, ctx) => {
|
|
502
|
+
return {
|
|
503
|
+
hasCtx: ctx != null,
|
|
504
|
+
hasConsole: typeof ctx?.console?.log === "function",
|
|
505
|
+
hasFs: typeof ctx?.fs?.readFile === "function",
|
|
506
|
+
cwd: ctx?.process?.cwd,
|
|
507
|
+
token: ctx?.process?.env?.TOKEN,
|
|
508
|
+
user: ctx?.process?.env?.USER,
|
|
509
|
+
stdin: ctx?.process?.stdin,
|
|
510
|
+
};
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
const result = await withMcpClient(cli, (client) =>
|
|
514
|
+
client.callTool({ name: "inspect-ctx", arguments: {} }),
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
expect(firstTextContent(result)).toMatchInlineSnapshot(`
|
|
518
|
+
"{
|
|
519
|
+
"hasCtx": true,
|
|
520
|
+
"hasConsole": true,
|
|
521
|
+
"hasFs": true,
|
|
522
|
+
"cwd": "/workspace",
|
|
523
|
+
"token": "abc",
|
|
524
|
+
"user": "tommy",
|
|
525
|
+
"stdin": "hello from stdin"
|
|
526
|
+
}"
|
|
527
|
+
`);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("captures ctx.console.log output into the tool result content", async () => {
|
|
531
|
+
const cli = goke("logs-cli");
|
|
532
|
+
|
|
533
|
+
cli.command("noisy", "Write to ctx.console and return nothing").action((_options, ctx) => {
|
|
534
|
+
ctx.console.log("line one");
|
|
535
|
+
ctx.console.log("line", "two");
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
const result = await withMcpClient(cli, (client) =>
|
|
539
|
+
client.callTool({ name: "noisy", arguments: {} }),
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
expect(textBlocks(result)).toMatchInlineSnapshot(`
|
|
543
|
+
[
|
|
544
|
+
"line one
|
|
545
|
+
line two
|
|
546
|
+
",
|
|
547
|
+
]
|
|
548
|
+
`);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("captures ctx.console.log output and still uses the action's return value", async () => {
|
|
552
|
+
const cli = goke("logs-plus-return-cli");
|
|
553
|
+
|
|
554
|
+
cli.command("both", "Log and return").action((_options, ctx) => {
|
|
555
|
+
ctx.console.log("before");
|
|
556
|
+
return "the-return-value";
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
const result = await withMcpClient(cli, (client) =>
|
|
560
|
+
client.callTool({ name: "both", arguments: {} }),
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
// Captured stdout first, then the stringified return value, as
|
|
564
|
+
// separate content blocks. Authors who want a single block can
|
|
565
|
+
// return a `{ content }` object to bypass this merging.
|
|
566
|
+
expect(textBlocks(result)).toMatchInlineSnapshot(`
|
|
567
|
+
[
|
|
568
|
+
"before
|
|
569
|
+
",
|
|
570
|
+
"the-return-value",
|
|
571
|
+
]
|
|
572
|
+
`);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("treats ctx.process.exit(0) as a success result with captured content", async () => {
|
|
576
|
+
const cli = goke("exit-ok-cli");
|
|
577
|
+
|
|
578
|
+
cli.command("exit-ok", "Exit cleanly").action((_options, ctx) => {
|
|
579
|
+
ctx.console.log("all good");
|
|
580
|
+
ctx.process.exit(0);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
const result = await withMcpClient(cli, (client) =>
|
|
584
|
+
client.callTool({ name: "exit-ok", arguments: {} }),
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
expect(result.isError).toBeFalsy();
|
|
588
|
+
expect(textBlocks(result)).toMatchInlineSnapshot(`
|
|
589
|
+
[
|
|
590
|
+
"all good
|
|
591
|
+
",
|
|
592
|
+
]
|
|
593
|
+
`);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("treats ctx.process.exit(1) as an isError result with captured stderr", async () => {
|
|
597
|
+
const cli = goke("exit-fail-cli");
|
|
598
|
+
|
|
599
|
+
cli.command("exit-fail", "Exit with error").action((_options, ctx) => {
|
|
600
|
+
ctx.console.error("boom");
|
|
601
|
+
ctx.process.exit(1);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
const result = await withMcpClient(cli, (client) =>
|
|
605
|
+
client.callTool({ name: "exit-fail", arguments: {} }),
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
expect(result.isError).toBe(true);
|
|
609
|
+
expect(textBlocks(result)).toMatchInlineSnapshot(`
|
|
610
|
+
[
|
|
611
|
+
"boom
|
|
612
|
+
",
|
|
613
|
+
]
|
|
614
|
+
`);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("does not corrupt the MCP transport when the action writes to ctx.process.stdout directly", async () => {
|
|
618
|
+
const cli = goke("stdout-cli");
|
|
619
|
+
|
|
620
|
+
cli.command("write-stdout", "Write through ctx.process.stdout").action((_options, ctx) => {
|
|
621
|
+
ctx.process.stdout.write("from-process-stdout\n");
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
const result = await withMcpClient(cli, (client) =>
|
|
625
|
+
client.callTool({ name: "write-stdout", arguments: {} }),
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
expect(firstTextContent(result)).toBe("from-process-stdout\n");
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("keeps the server alive after a tool action calls ctx.process.exit", async () => {
|
|
632
|
+
const cli = goke("survive-cli");
|
|
633
|
+
|
|
634
|
+
cli.command("boom", "Exit with non-zero code").action((_options, ctx) => {
|
|
635
|
+
ctx.process.exit(2);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
cli.command("ping", "Return a value").action(() => "pong");
|
|
639
|
+
|
|
640
|
+
await withMcpClient(cli, async (client) => {
|
|
641
|
+
const boomResult = await client.callTool({ name: "boom", arguments: {} });
|
|
642
|
+
expect(boomResult.isError).toBe(true);
|
|
643
|
+
|
|
644
|
+
// Server must still be able to serve subsequent tool calls.
|
|
645
|
+
const pingResult = await client.callTool({ name: "ping", arguments: {} });
|
|
646
|
+
expect(firstTextContent(pingResult)).toBe("pong");
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it("does not include captured content when the action returns a ready-made CallToolResult", async () => {
|
|
651
|
+
const cli = goke("raw-cli");
|
|
652
|
+
|
|
653
|
+
cli.command("raw", "Return a raw CallToolResult").action((_options, ctx) => {
|
|
654
|
+
// This write should be ignored — returning a {content} object is
|
|
655
|
+
// the explicit escape hatch for authors who want full control.
|
|
656
|
+
ctx.console.log("ignored-capture");
|
|
657
|
+
return {
|
|
658
|
+
content: [
|
|
659
|
+
{ type: "text" as const, text: "authoritative" },
|
|
660
|
+
],
|
|
661
|
+
};
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
const result = await withMcpClient(cli, (client) =>
|
|
665
|
+
client.callTool({ name: "raw", arguments: {} }),
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
expect(textBlocks(result)).toEqual(["authoritative"]);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("captures ctx.console.error output on the success path", async () => {
|
|
672
|
+
const cli = goke("success-stderr-cli");
|
|
673
|
+
|
|
674
|
+
cli.command("warn-and-return", "Emit a warning and return a value").action((_options, ctx) => {
|
|
675
|
+
ctx.console.error("something suspicious");
|
|
676
|
+
return { ok: true };
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
const result = await withMcpClient(cli, (client) =>
|
|
680
|
+
client.callTool({ name: "warn-and-return", arguments: {} }),
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
// Captured stderr lands in its own text block so authors can spot
|
|
684
|
+
// the warning even though the action returned successfully. The
|
|
685
|
+
// stringified return value is appended after it.
|
|
686
|
+
expect(result.isError).toBeFalsy();
|
|
687
|
+
expect(textBlocks(result)).toMatchInlineSnapshot(`
|
|
688
|
+
[
|
|
689
|
+
"something suspicious
|
|
690
|
+
",
|
|
691
|
+
"{
|
|
692
|
+
"ok": true
|
|
693
|
+
}",
|
|
694
|
+
]
|
|
695
|
+
`);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("captures ctx.process.stderr.write output on the success path", async () => {
|
|
699
|
+
const cli = goke("success-stderr-write-cli");
|
|
700
|
+
|
|
701
|
+
cli.command("warn-only", "Write to stderr and return undefined").action((_options, ctx) => {
|
|
702
|
+
ctx.process.stderr.write("low-level-warning\n");
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
const result = await withMcpClient(cli, (client) =>
|
|
706
|
+
client.callTool({ name: "warn-only", arguments: {} }),
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
expect(result.isError).toBeFalsy();
|
|
710
|
+
expect(textBlocks(result)).toEqual(["low-level-warning\n"]);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("does not leak tool output into the cli's configured stdout/stderr", async () => {
|
|
714
|
+
const sentinelStdout: string[] = [];
|
|
715
|
+
const sentinelStderr: string[] = [];
|
|
716
|
+
const cli = goke("sentinel-cli", {
|
|
717
|
+
stdout: { write: (data) => { sentinelStdout.push(data); } },
|
|
718
|
+
stderr: { write: (data) => { sentinelStderr.push(data); } },
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
cli.command("noisy", "Write to both streams").action((_options, ctx) => {
|
|
722
|
+
ctx.console.log("stdout-chatter");
|
|
723
|
+
ctx.console.error("stderr-chatter");
|
|
724
|
+
ctx.process.stdout.write("direct-stdout\n");
|
|
725
|
+
ctx.process.stderr.write("direct-stderr\n");
|
|
726
|
+
return "value";
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
const result = await withMcpClient(cli, (client) =>
|
|
730
|
+
client.callTool({ name: "noisy", arguments: {} }),
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
// Everything lands in the CallToolResult — the cli's configured
|
|
734
|
+
// host streams must not receive a single byte during a tool call.
|
|
735
|
+
expect(sentinelStdout.join("")).toBe("");
|
|
736
|
+
expect(sentinelStderr.join("")).toBe("");
|
|
737
|
+
expect(textBlocks(result).join("|")).toBe(
|
|
738
|
+
"stdout-chatter\ndirect-stdout\n|stderr-chatter\ndirect-stderr\n|value",
|
|
739
|
+
);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it("invokes command actions with the owning cli as `this`", async () => {
|
|
743
|
+
const cli = goke("this-binding-cli");
|
|
744
|
+
|
|
745
|
+
let seenThis: unknown;
|
|
746
|
+
cli.command("whoami", "Report this-binding").action(function (this: unknown, _options, _ctx) {
|
|
747
|
+
seenThis = this;
|
|
748
|
+
return "ok";
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
await withMcpClient(cli, (client) =>
|
|
752
|
+
client.callTool({ name: "whoami", arguments: {} }),
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
// Same binding Goke#runMatchedCommand uses for parse-path actions.
|
|
756
|
+
expect(seenThis).toBe(cli);
|
|
757
|
+
});
|
|
758
|
+
});
|