@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.
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Tests for createMcpAction — turning a CLI into a stdio MCP server.
3
+ *
4
+ * Uses InMemoryTransport (via createTransport option) to avoid actual stdio.
5
+ * Simulates the goke runtime by setting matchedCommandName before calling the action.
6
+ */
7
+
8
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
9
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
10
+ import { goke, wrapJsonSchema } from "goke";
11
+ import { z } from "zod";
12
+ import { describe, expect, it } from "vitest";
13
+ import { createMcpAction } from "../cli-to-mcp.js";
14
+
15
+ function firstTextContent(result: Awaited<ReturnType<Client["callTool"]>>): string {
16
+ const content = "content" in result ? (result as { content: Array<{ type: string; text?: string }> }).content : [];
17
+ return content.find((entry) => entry.type === "text")?.text ?? "";
18
+ }
19
+
20
+ describe("createMcpAction", () => {
21
+ it("returns an action function", () => {
22
+ const cli = goke("test");
23
+ const action = createMcpAction({ cli });
24
+ expect(typeof action).toBe("function");
25
+ });
26
+
27
+ it("starts an MCP server exposing CLI commands, excluding the mcp command", async () => {
28
+ const cli = goke("test");
29
+
30
+ cli
31
+ .command("greet", "Say hello")
32
+ .option("--name <name>", z.string().describe("Person to greet"))
33
+ .action((options) => `Hello ${options.name}!`);
34
+
35
+ cli
36
+ .command("add", "Add numbers")
37
+ .option("--a <a>", z.number().describe("First"))
38
+ .option("--b <b>", z.number().describe("Second"))
39
+ .action((options) => ({ sum: options.a + options.b }));
40
+
41
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
42
+
43
+ cli.command("mcp", "Start MCP server").action(
44
+ createMcpAction({
45
+ cli,
46
+ createTransport: () => serverTransport,
47
+ }),
48
+ );
49
+
50
+ // Simulate goke matching the "mcp" command (normally set by cli.parse())
51
+ cli.matchedCommandName = "mcp";
52
+
53
+ // Fire the action — starts the MCP server on the in-memory transport
54
+ const mcpCommand = cli.commands.find((c) => c.name === "mcp")!;
55
+ await mcpCommand.commandAction!({});
56
+
57
+ const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
58
+ await client.connect(clientTransport);
59
+
60
+ try {
61
+ const tools = await client.listTools();
62
+ const toolNames = tools.tools.map((t) => t.name).sort();
63
+ expect(toolNames).toEqual(["add", "greet"]);
64
+
65
+ const greetResult = await client.callTool({
66
+ name: "greet",
67
+ arguments: { name: "World" },
68
+ });
69
+ expect(firstTextContent(greetResult)).toBe("Hello World!");
70
+
71
+ const addResult = await client.callTool({
72
+ name: "add",
73
+ arguments: { a: 3, b: 7 },
74
+ });
75
+ expect(firstTextContent(addResult)).toBe('{\n "sum": 10\n}');
76
+ } finally {
77
+ await client.close();
78
+ }
79
+ });
80
+
81
+ it("composes user commandFilter with auto-exclusion", async () => {
82
+ const cli = goke("test");
83
+
84
+ cli.command("public-cmd", "Public command").action(() => "public");
85
+ cli.command("secret-cmd", "Secret command").action(() => "secret");
86
+
87
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
88
+
89
+ cli.command("mcp", "Start MCP server").action(
90
+ createMcpAction({
91
+ cli,
92
+ commandFilter: (name) => name !== "secret-cmd",
93
+ createTransport: () => serverTransport,
94
+ }),
95
+ );
96
+
97
+ cli.matchedCommandName = "mcp";
98
+
99
+ const mcpCommand = cli.commands.find((c) => c.name === "mcp")!;
100
+ await mcpCommand.commandAction!({});
101
+
102
+ const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
103
+ await client.connect(clientTransport);
104
+
105
+ try {
106
+ const tools = await client.listTools();
107
+ const toolNames = tools.tools.map((t) => t.name).sort();
108
+ // Both "mcp" (auto-excluded) and "secret-cmd" (user filter) excluded
109
+ expect(toolNames).toEqual(["public-cmd"]);
110
+ } finally {
111
+ await client.close();
112
+ }
113
+ });
114
+
115
+ it("works with multi-word command names", async () => {
116
+ const cli = goke("test");
117
+
118
+ cli
119
+ .command("db migrate", "Run migrations")
120
+ .action(() => "migrated");
121
+
122
+ cli
123
+ .command("db seed", "Seed database")
124
+ .action(() => "seeded");
125
+
126
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
127
+
128
+ cli.command("serve mcp", "Start MCP server").action(
129
+ createMcpAction({
130
+ cli,
131
+ createTransport: () => serverTransport,
132
+ }),
133
+ );
134
+
135
+ cli.matchedCommandName = "serve mcp";
136
+
137
+ const mcpCommand = cli.commands.find((c) => c.name === "serve mcp")!;
138
+ await mcpCommand.commandAction!({});
139
+
140
+ const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
141
+ await client.connect(clientTransport);
142
+
143
+ try {
144
+ const tools = await client.listTools();
145
+ const toolNames = tools.tools.map((t) => t.name).sort();
146
+ expect(toolNames).toEqual(["db_migrate", "db_seed"]);
147
+
148
+ const migrateResult = await client.callTool({
149
+ name: "db_migrate",
150
+ arguments: {},
151
+ });
152
+ expect(firstTextContent(migrateResult)).toBe("migrated");
153
+ } finally {
154
+ await client.close();
155
+ }
156
+ });
157
+
158
+ it("end-to-end: MCP client connects, discovers tools with schemas, calls tools, handles errors", async () => {
159
+ const cli = goke("my-app");
160
+
161
+ // String option command
162
+ cli
163
+ .command("search", "Search for items")
164
+ .option("--query <query>", z.string().describe("Search query"))
165
+ .option("--limit [limit]", z.number().default(10).describe("Max results"))
166
+ .action((options) => {
167
+ return { results: [`result for "${options.query}"`], limit: options.limit };
168
+ });
169
+
170
+ // Boolean flag + positional arg command
171
+ cli
172
+ .command("deploy <env>", "Deploy to environment")
173
+ .option("--dry-run", z.boolean().default(false).describe("Simulate deployment"))
174
+ .action((env, options) => {
175
+ return options.dryRun ? `dry-run deploy to ${env}` : `deployed to ${env}`;
176
+ });
177
+
178
+ // Command that returns a CallToolResult directly
179
+ cli
180
+ .command("status", "Get system status")
181
+ .action(() => ({
182
+ content: [{ type: "text", text: "all systems operational" }],
183
+ }));
184
+
185
+ // Command that throws an error (should be caught and returned as isError)
186
+ cli
187
+ .command("fail", "Always fails")
188
+ .action(() => {
189
+ throw new Error("something went wrong");
190
+ });
191
+
192
+ // Wrapped JSON schema command.
193
+ // wrapJsonSchema produces a StandardJSONSchemaV1 with `unknown` output, so
194
+ // values are cast explicitly inside the action.
195
+ cli
196
+ .command("config set", "Set a config value")
197
+ .option("--key <key>", wrapJsonSchema({ type: "string", description: "Config key" }))
198
+ .option("--value <value>", wrapJsonSchema({ type: "string", description: "Config value" }))
199
+ .action((options) => {
200
+ return `set ${String(options.key)} = ${String(options.value)}`;
201
+ });
202
+
203
+ // Commands without actions (should NOT appear as tools)
204
+ cli.command("no-action", "This has no action handler");
205
+
206
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
207
+
208
+ cli.command("mcp", "Start MCP server").action(
209
+ createMcpAction({
210
+ cli,
211
+ serverName: "my-app-mcp",
212
+ serverVersion: "2.5.0",
213
+ createTransport: () => serverTransport,
214
+ }),
215
+ );
216
+
217
+ cli.matchedCommandName = "mcp";
218
+
219
+ const mcpCommand = cli.commands.find((c) => c.name === "mcp")!;
220
+ await mcpCommand.commandAction!({});
221
+
222
+ const client = new Client({ name: "e2e-test-client", version: "1.0.0" }, { capabilities: {} });
223
+ await client.connect(clientTransport);
224
+
225
+ try {
226
+ // ── Server info ──
227
+ const serverInfo = client.getServerVersion();
228
+ expect(serverInfo).toMatchObject({ name: "my-app-mcp", version: "2.5.0" });
229
+
230
+ // ── Tool discovery ──
231
+ const tools = await client.listTools();
232
+ const toolNames = tools.tools.map((t) => t.name).sort();
233
+ // "mcp" auto-excluded, "no-action" has no handler → not mounted
234
+ expect(toolNames).toEqual(["config_set", "deploy", "fail", "search", "status"]);
235
+
236
+ // ── Verify schemas are propagated ──
237
+ const searchTool = tools.tools.find((t) => t.name === "search")!;
238
+ expect(searchTool.description).toBe("Search for items");
239
+ expect(searchTool.inputSchema.properties).toHaveProperty("query");
240
+ expect(searchTool.inputSchema.properties).toHaveProperty("limit");
241
+ expect(searchTool.inputSchema.required).toEqual(["query"]);
242
+
243
+ const deployTool = tools.tools.find((t) => t.name === "deploy")!;
244
+ expect(deployTool.inputSchema.properties).toHaveProperty("env");
245
+ expect(deployTool.inputSchema.properties).toHaveProperty("dryRun");
246
+ expect(deployTool.inputSchema.required).toEqual(["env"]);
247
+
248
+ // ── Call tool with schema-based options ──
249
+ const searchResult = await client.callTool({
250
+ name: "search",
251
+ arguments: { query: "hello", limit: 5 },
252
+ });
253
+ expect(firstTextContent(searchResult)).toBe(
254
+ '{\n "results": [\n "result for \\"hello\\""\n ],\n "limit": 5\n}',
255
+ );
256
+
257
+ // ── Call tool with positional args ──
258
+ const deployResult = await client.callTool({
259
+ name: "deploy",
260
+ arguments: { env: "production", dryRun: true },
261
+ });
262
+ expect(firstTextContent(deployResult)).toBe("dry-run deploy to production");
263
+
264
+ // ── Call tool that returns a raw CallToolResult ──
265
+ const statusResult = await client.callTool({
266
+ name: "status",
267
+ arguments: {},
268
+ });
269
+ expect(firstTextContent(statusResult)).toBe("all systems operational");
270
+
271
+ // ── Call tool that throws → error is caught and returned as isError ──
272
+ const failResult = await client.callTool({
273
+ name: "fail",
274
+ arguments: {},
275
+ });
276
+ expect(failResult.isError).toBe(true);
277
+ expect(firstTextContent(failResult)).toBe("something went wrong");
278
+
279
+ // ── Call tool with multi-word command name ──
280
+ const configResult = await client.callTool({
281
+ name: "config_set",
282
+ arguments: { key: "theme", value: "dark" },
283
+ });
284
+ expect(firstTextContent(configResult)).toBe("set theme = dark");
285
+
286
+ // ── Call nonexistent tool → MCP error ──
287
+ await expect(
288
+ client.callTool({ name: "nonexistent", arguments: {} }),
289
+ ).rejects.toThrow(/not found/i);
290
+ } finally {
291
+ await client.close();
292
+ }
293
+ });
294
+
295
+ it("returns empty tool list when only the mcp command exists", async () => {
296
+ const cli = goke("empty-app");
297
+
298
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
299
+
300
+ cli.command("mcp", "Start MCP server").action(
301
+ createMcpAction({
302
+ cli,
303
+ createTransport: () => serverTransport,
304
+ }),
305
+ );
306
+
307
+ cli.matchedCommandName = "mcp";
308
+
309
+ const mcpCommand = cli.commands.find((c) => c.name === "mcp")!;
310
+ await mcpCommand.commandAction!({});
311
+
312
+ const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
313
+ await client.connect(clientTransport);
314
+
315
+ try {
316
+ const tools = await client.listTools();
317
+ expect(tools.tools).toEqual([]);
318
+ } finally {
319
+ await client.close();
320
+ }
321
+ });
322
+
323
+ it("exposes all commands when matchedCommandName is not set", async () => {
324
+ const cli = goke("test");
325
+
326
+ cli.command("ping", "Ping").action(() => "pong");
327
+
328
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
329
+
330
+ cli.command("mcp", "Start MCP server").action(
331
+ createMcpAction({
332
+ cli,
333
+ createTransport: () => serverTransport,
334
+ }),
335
+ );
336
+
337
+ // Do NOT set matchedCommandName — simulates programmatic invocation
338
+ // without cli.parse(). All commands including "mcp" should be exposed.
339
+
340
+ const mcpCommand = cli.commands.find((c) => c.name === "mcp")!;
341
+ await mcpCommand.commandAction!({});
342
+
343
+ const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
344
+ await client.connect(clientTransport);
345
+
346
+ try {
347
+ const tools = await client.listTools();
348
+ const toolNames = tools.tools.map((t) => t.name).sort();
349
+ // Without matchedCommandName, auto-exclusion can't kick in
350
+ expect(toolNames).toEqual(["mcp", "ping"]);
351
+ } finally {
352
+ await client.close();
353
+ }
354
+ });
355
+ });