@goke/mcp 0.0.8 → 0.0.9
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 +86 -31
- 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 +274 -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 +44 -0
- 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__/create-mcp-action.test.ts +353 -0
- package/src/cli-to-mcp.ts +68 -0
- package/src/index.ts +2 -2
package/README.md
CHANGED
|
@@ -62,60 +62,114 @@ notion-mcp-cli notion-retrieve-page --page_id "abc123"
|
|
|
62
62
|
notion-mcp-cli notion-list-users
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
##
|
|
65
|
+
## Expose a CLI as an MCP server
|
|
66
66
|
|
|
67
|
-
`
|
|
68
|
-
|
|
69
|
-
- Command description → MCP tool description
|
|
70
|
-
- Option schema (Zod or any Standard Schema library) → MCP `inputSchema` JSON Schema
|
|
71
|
-
- Command names are sanitized into valid MCP tool names (invalid characters become `_`)
|
|
72
|
-
- Composable with existing MCP tools already registered on the same server
|
|
73
|
-
|
|
74
|
-
### With low-level `Server`
|
|
67
|
+
`createMcpAction()` turns your entire CLI into a stdio MCP server with one line. Every CLI command becomes an MCP tool automatically. The command you attach it to is excluded from the tool list.
|
|
75
68
|
|
|
76
69
|
```ts
|
|
77
70
|
import { goke } from "goke"
|
|
78
71
|
import { z } from "zod"
|
|
79
|
-
import {
|
|
80
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
81
|
-
import { addCliToolsToMcp } from "@goke/mcp"
|
|
72
|
+
import { createMcpAction } from "@goke/mcp"
|
|
82
73
|
|
|
83
74
|
const cli = goke("my-cli")
|
|
84
75
|
|
|
85
76
|
cli
|
|
86
|
-
.command("
|
|
77
|
+
.command("search", "Search pages")
|
|
87
78
|
.option("--query <query>", z.string().describe("Search query"))
|
|
88
|
-
.
|
|
79
|
+
.option("--limit [limit]", z.number().default(10).describe("Max results"))
|
|
80
|
+
.action((options) => {
|
|
81
|
+
return { results: findPages(options.query, options.limit) }
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
cli
|
|
85
|
+
.command("deploy <env>", "Deploy to environment")
|
|
86
|
+
.option("--dry-run", z.boolean().default(false).describe("Simulate"))
|
|
87
|
+
.action((env, options) => {
|
|
88
|
+
return options.dryRun ? `would deploy to ${env}` : deploy(env)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Add MCP support — runs a stdio MCP server when the user invokes `my-cli mcp`
|
|
92
|
+
cli.command("mcp", "Start MCP server over stdio")
|
|
93
|
+
.action(createMcpAction({ cli }))
|
|
94
|
+
|
|
95
|
+
cli.help()
|
|
96
|
+
cli.parse()
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Now users can use your CLI directly **or** connect it as an MCP server:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
# Use as a normal CLI
|
|
103
|
+
my-cli search --query "meeting notes"
|
|
104
|
+
my-cli deploy staging --dry-run
|
|
105
|
+
|
|
106
|
+
# Use as an MCP server (e.g. from Claude Desktop, Cursor, etc.)
|
|
107
|
+
my-cli mcp
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
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.
|
|
111
|
+
|
|
112
|
+
### Installing the MCP server in clients
|
|
113
|
+
|
|
114
|
+
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:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# Install in Claude Desktop
|
|
118
|
+
npx @playwriter/install-mcp my-cli --client claude-desktop
|
|
119
|
+
|
|
120
|
+
# Install in Cursor
|
|
121
|
+
npx @playwriter/install-mcp my-cli --client cursor
|
|
122
|
+
|
|
123
|
+
# Install in VS Code
|
|
124
|
+
npx @playwriter/install-mcp my-cli --client vscode
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
This works with any client: `claude-desktop`, `cursor`, `vscode`, `windsurf`, `claude-code`, `opencode`, `zed`, `goose`, `cline`, `codex`, `gemini-cli`, and [more](https://github.com/supermemoryai/install-mcp#supported-clients). If the command needs custom arguments, pass the full command string:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
npx @playwriter/install-mcp 'npx my-cli mcp' --client cursor
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`createMcpAction` accepts the same filtering options as `addCliToolsToMcp`:
|
|
134
|
+
|
|
135
|
+
| Option | Type | Default | Description |
|
|
136
|
+
|--------|------|---------|-------------|
|
|
137
|
+
| `cli` | `Goke` | **required** | The CLI instance to expose |
|
|
138
|
+
| `commandFilter` | `(name) => boolean` | — | Additional filter (MCP command is always excluded) |
|
|
139
|
+
| `sanitizeToolName` | `(name) => string` | — | Custom tool name sanitizer |
|
|
140
|
+
| `serverName` | `string` | CLI name | MCP server name |
|
|
141
|
+
| `serverVersion` | `string` | `'1.0.0'` | MCP server version |
|
|
142
|
+
| `createTransport` | `() => Transport` | stdio | Custom transport factory |
|
|
143
|
+
|
|
144
|
+
### Advanced: `addCliToolsToMcp`
|
|
145
|
+
|
|
146
|
+
For more control (composing with existing MCP tools, using a custom server), use `addCliToolsToMcp()` directly:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
|
150
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
151
|
+
import { addCliToolsToMcp } from "@goke/mcp"
|
|
89
152
|
|
|
90
153
|
const server = new Server(
|
|
91
154
|
{ name: "my-cli-mcp", version: "1.0.0" },
|
|
92
155
|
{ capabilities: {} },
|
|
93
156
|
)
|
|
94
157
|
|
|
158
|
+
// Mount CLI commands as tools alongside your own
|
|
95
159
|
addCliToolsToMcp({ cli, server })
|
|
96
160
|
|
|
97
161
|
const transport = new StdioServerTransport()
|
|
98
162
|
await server.connect(transport)
|
|
99
163
|
```
|
|
100
164
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
```bash
|
|
104
|
-
node dist/server.js
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
### With high-level `McpServer`
|
|
165
|
+
Also works with the high-level `McpServer`:
|
|
108
166
|
|
|
109
167
|
```ts
|
|
110
168
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
|
111
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
112
|
-
import { addCliToolsToMcp } from "@goke/mcp"
|
|
113
169
|
|
|
114
170
|
const mcp = new McpServer({ name: "my-cli-mcp", version: "1.0.0" })
|
|
171
|
+
mcp.tool("custom-tool", "A tool defined directly", async () => ({ ... }))
|
|
115
172
|
addCliToolsToMcp({ cli, server: mcp })
|
|
116
|
-
|
|
117
|
-
const transport = new StdioServerTransport()
|
|
118
|
-
await mcp.connect(transport)
|
|
119
173
|
```
|
|
120
174
|
|
|
121
175
|
## Full example (with config persistence)
|
|
@@ -220,13 +274,14 @@ Registers MCP tool commands on a goke CLI instance.
|
|
|
220
274
|
### Exports
|
|
221
275
|
|
|
222
276
|
```ts
|
|
223
|
-
//
|
|
277
|
+
// MCP server → CLI (consume MCP tools as CLI commands)
|
|
224
278
|
export { addMcpCommands } from '@goke/mcp'
|
|
225
|
-
|
|
226
|
-
// Types
|
|
227
|
-
export type { AddMcpCommandsOptions } from '@goke/mcp'
|
|
228
|
-
export type { CachedMcpTools } from '@goke/mcp'
|
|
279
|
+
export type { AddMcpCommandsOptions, CachedMcpTools } from '@goke/mcp'
|
|
229
280
|
export type { McpOAuthConfig, McpOAuthState } from '@goke/mcp'
|
|
281
|
+
|
|
282
|
+
// CLI → MCP server (expose CLI commands as MCP tools)
|
|
283
|
+
export { createMcpAction, addCliToolsToMcp } from '@goke/mcp'
|
|
284
|
+
export type { CreateMcpActionOptions, AddCliToolsToMcpOptions } from '@goke/mcp'
|
|
230
285
|
```
|
|
231
286
|
|
|
232
287
|
## OAuth flow
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=create-mcp-action.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-mcp-action.test.d.ts","sourceRoot":"","sources":["../../src/__test__/create-mcp-action.test.ts"],"names":[],"mappings":"AAAA;;;;;GAKG"}
|
|
@@ -0,0 +1,274 @@
|
|
|
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
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
9
|
+
import { goke, wrapJsonSchema } from "goke";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import { createMcpAction } from "../cli-to-mcp.js";
|
|
13
|
+
function firstTextContent(result) {
|
|
14
|
+
const content = "content" in result ? result.content : [];
|
|
15
|
+
return content.find((entry) => entry.type === "text")?.text ?? "";
|
|
16
|
+
}
|
|
17
|
+
describe("createMcpAction", () => {
|
|
18
|
+
it("returns an action function", () => {
|
|
19
|
+
const cli = goke("test");
|
|
20
|
+
const action = createMcpAction({ cli });
|
|
21
|
+
expect(typeof action).toBe("function");
|
|
22
|
+
});
|
|
23
|
+
it("starts an MCP server exposing CLI commands, excluding the mcp command", async () => {
|
|
24
|
+
const cli = goke("test");
|
|
25
|
+
cli
|
|
26
|
+
.command("greet", "Say hello")
|
|
27
|
+
.option("--name <name>", z.string().describe("Person to greet"))
|
|
28
|
+
.action((options) => `Hello ${options.name}!`);
|
|
29
|
+
cli
|
|
30
|
+
.command("add", "Add numbers")
|
|
31
|
+
.option("--a <a>", z.number().describe("First"))
|
|
32
|
+
.option("--b <b>", z.number().describe("Second"))
|
|
33
|
+
.action((options) => ({ sum: options.a + options.b }));
|
|
34
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
35
|
+
cli.command("mcp", "Start MCP server").action(createMcpAction({
|
|
36
|
+
cli,
|
|
37
|
+
createTransport: () => serverTransport,
|
|
38
|
+
}));
|
|
39
|
+
// Simulate goke matching the "mcp" command (normally set by cli.parse())
|
|
40
|
+
cli.matchedCommandName = "mcp";
|
|
41
|
+
// Fire the action — starts the MCP server on the in-memory transport
|
|
42
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp");
|
|
43
|
+
await mcpCommand.commandAction({});
|
|
44
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
45
|
+
await client.connect(clientTransport);
|
|
46
|
+
try {
|
|
47
|
+
const tools = await client.listTools();
|
|
48
|
+
const toolNames = tools.tools.map((t) => t.name).sort();
|
|
49
|
+
expect(toolNames).toEqual(["add", "greet"]);
|
|
50
|
+
const greetResult = await client.callTool({
|
|
51
|
+
name: "greet",
|
|
52
|
+
arguments: { name: "World" },
|
|
53
|
+
});
|
|
54
|
+
expect(firstTextContent(greetResult)).toBe("Hello World!");
|
|
55
|
+
const addResult = await client.callTool({
|
|
56
|
+
name: "add",
|
|
57
|
+
arguments: { a: 3, b: 7 },
|
|
58
|
+
});
|
|
59
|
+
expect(firstTextContent(addResult)).toBe('{\n "sum": 10\n}');
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await client.close();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
it("composes user commandFilter with auto-exclusion", async () => {
|
|
66
|
+
const cli = goke("test");
|
|
67
|
+
cli.command("public-cmd", "Public command").action(() => "public");
|
|
68
|
+
cli.command("secret-cmd", "Secret command").action(() => "secret");
|
|
69
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
70
|
+
cli.command("mcp", "Start MCP server").action(createMcpAction({
|
|
71
|
+
cli,
|
|
72
|
+
commandFilter: (name) => name !== "secret-cmd",
|
|
73
|
+
createTransport: () => serverTransport,
|
|
74
|
+
}));
|
|
75
|
+
cli.matchedCommandName = "mcp";
|
|
76
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp");
|
|
77
|
+
await mcpCommand.commandAction({});
|
|
78
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
79
|
+
await client.connect(clientTransport);
|
|
80
|
+
try {
|
|
81
|
+
const tools = await client.listTools();
|
|
82
|
+
const toolNames = tools.tools.map((t) => t.name).sort();
|
|
83
|
+
// Both "mcp" (auto-excluded) and "secret-cmd" (user filter) excluded
|
|
84
|
+
expect(toolNames).toEqual(["public-cmd"]);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await client.close();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
it("works with multi-word command names", async () => {
|
|
91
|
+
const cli = goke("test");
|
|
92
|
+
cli
|
|
93
|
+
.command("db migrate", "Run migrations")
|
|
94
|
+
.action(() => "migrated");
|
|
95
|
+
cli
|
|
96
|
+
.command("db seed", "Seed database")
|
|
97
|
+
.action(() => "seeded");
|
|
98
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
99
|
+
cli.command("serve mcp", "Start MCP server").action(createMcpAction({
|
|
100
|
+
cli,
|
|
101
|
+
createTransport: () => serverTransport,
|
|
102
|
+
}));
|
|
103
|
+
cli.matchedCommandName = "serve mcp";
|
|
104
|
+
const mcpCommand = cli.commands.find((c) => c.name === "serve mcp");
|
|
105
|
+
await mcpCommand.commandAction({});
|
|
106
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
107
|
+
await client.connect(clientTransport);
|
|
108
|
+
try {
|
|
109
|
+
const tools = await client.listTools();
|
|
110
|
+
const toolNames = tools.tools.map((t) => t.name).sort();
|
|
111
|
+
expect(toolNames).toEqual(["db_migrate", "db_seed"]);
|
|
112
|
+
const migrateResult = await client.callTool({
|
|
113
|
+
name: "db_migrate",
|
|
114
|
+
arguments: {},
|
|
115
|
+
});
|
|
116
|
+
expect(firstTextContent(migrateResult)).toBe("migrated");
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
await client.close();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
it("end-to-end: MCP client connects, discovers tools with schemas, calls tools, handles errors", async () => {
|
|
123
|
+
const cli = goke("my-app");
|
|
124
|
+
// String option command
|
|
125
|
+
cli
|
|
126
|
+
.command("search", "Search for items")
|
|
127
|
+
.option("--query <query>", z.string().describe("Search query"))
|
|
128
|
+
.option("--limit [limit]", z.number().default(10).describe("Max results"))
|
|
129
|
+
.action((options) => {
|
|
130
|
+
return { results: [`result for "${options.query}"`], limit: options.limit };
|
|
131
|
+
});
|
|
132
|
+
// Boolean flag + positional arg command
|
|
133
|
+
cli
|
|
134
|
+
.command("deploy <env>", "Deploy to environment")
|
|
135
|
+
.option("--dry-run", z.boolean().default(false).describe("Simulate deployment"))
|
|
136
|
+
.action((env, options) => {
|
|
137
|
+
return options.dryRun ? `dry-run deploy to ${env}` : `deployed to ${env}`;
|
|
138
|
+
});
|
|
139
|
+
// Command that returns a CallToolResult directly
|
|
140
|
+
cli
|
|
141
|
+
.command("status", "Get system status")
|
|
142
|
+
.action(() => ({
|
|
143
|
+
content: [{ type: "text", text: "all systems operational" }],
|
|
144
|
+
}));
|
|
145
|
+
// Command that throws an error (should be caught and returned as isError)
|
|
146
|
+
cli
|
|
147
|
+
.command("fail", "Always fails")
|
|
148
|
+
.action(() => {
|
|
149
|
+
throw new Error("something went wrong");
|
|
150
|
+
});
|
|
151
|
+
// Wrapped JSON schema command
|
|
152
|
+
cli
|
|
153
|
+
.command("config set", "Set a config value")
|
|
154
|
+
.option("--key <key>", wrapJsonSchema({ type: "string", description: "Config key" }))
|
|
155
|
+
.option("--value <value>", wrapJsonSchema({ type: "string", description: "Config value" }))
|
|
156
|
+
.action((options) => {
|
|
157
|
+
return `set ${options.key} = ${options.value}`;
|
|
158
|
+
});
|
|
159
|
+
// Commands without actions (should NOT appear as tools)
|
|
160
|
+
cli.command("no-action", "This has no action handler");
|
|
161
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
162
|
+
cli.command("mcp", "Start MCP server").action(createMcpAction({
|
|
163
|
+
cli,
|
|
164
|
+
serverName: "my-app-mcp",
|
|
165
|
+
serverVersion: "2.5.0",
|
|
166
|
+
createTransport: () => serverTransport,
|
|
167
|
+
}));
|
|
168
|
+
cli.matchedCommandName = "mcp";
|
|
169
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp");
|
|
170
|
+
await mcpCommand.commandAction({});
|
|
171
|
+
const client = new Client({ name: "e2e-test-client", version: "1.0.0" }, { capabilities: {} });
|
|
172
|
+
await client.connect(clientTransport);
|
|
173
|
+
try {
|
|
174
|
+
// ── Server info ──
|
|
175
|
+
const serverInfo = client.getServerVersion();
|
|
176
|
+
expect(serverInfo).toMatchObject({ name: "my-app-mcp", version: "2.5.0" });
|
|
177
|
+
// ── Tool discovery ──
|
|
178
|
+
const tools = await client.listTools();
|
|
179
|
+
const toolNames = tools.tools.map((t) => t.name).sort();
|
|
180
|
+
// "mcp" auto-excluded, "no-action" has no handler → not mounted
|
|
181
|
+
expect(toolNames).toEqual(["config_set", "deploy", "fail", "search", "status"]);
|
|
182
|
+
// ── Verify schemas are propagated ──
|
|
183
|
+
const searchTool = tools.tools.find((t) => t.name === "search");
|
|
184
|
+
expect(searchTool.description).toBe("Search for items");
|
|
185
|
+
expect(searchTool.inputSchema.properties).toHaveProperty("query");
|
|
186
|
+
expect(searchTool.inputSchema.properties).toHaveProperty("limit");
|
|
187
|
+
expect(searchTool.inputSchema.required).toEqual(["query"]);
|
|
188
|
+
const deployTool = tools.tools.find((t) => t.name === "deploy");
|
|
189
|
+
expect(deployTool.inputSchema.properties).toHaveProperty("env");
|
|
190
|
+
expect(deployTool.inputSchema.properties).toHaveProperty("dryRun");
|
|
191
|
+
expect(deployTool.inputSchema.required).toEqual(["env"]);
|
|
192
|
+
// ── Call tool with schema-based options ──
|
|
193
|
+
const searchResult = await client.callTool({
|
|
194
|
+
name: "search",
|
|
195
|
+
arguments: { query: "hello", limit: 5 },
|
|
196
|
+
});
|
|
197
|
+
expect(firstTextContent(searchResult)).toBe('{\n "results": [\n "result for \\"hello\\""\n ],\n "limit": 5\n}');
|
|
198
|
+
// ── Call tool with positional args ──
|
|
199
|
+
const deployResult = await client.callTool({
|
|
200
|
+
name: "deploy",
|
|
201
|
+
arguments: { env: "production", dryRun: true },
|
|
202
|
+
});
|
|
203
|
+
expect(firstTextContent(deployResult)).toBe("dry-run deploy to production");
|
|
204
|
+
// ── Call tool that returns a raw CallToolResult ──
|
|
205
|
+
const statusResult = await client.callTool({
|
|
206
|
+
name: "status",
|
|
207
|
+
arguments: {},
|
|
208
|
+
});
|
|
209
|
+
expect(firstTextContent(statusResult)).toBe("all systems operational");
|
|
210
|
+
// ── Call tool that throws → error is caught and returned as isError ──
|
|
211
|
+
const failResult = await client.callTool({
|
|
212
|
+
name: "fail",
|
|
213
|
+
arguments: {},
|
|
214
|
+
});
|
|
215
|
+
expect(failResult.isError).toBe(true);
|
|
216
|
+
expect(firstTextContent(failResult)).toBe("something went wrong");
|
|
217
|
+
// ── Call tool with multi-word command name ──
|
|
218
|
+
const configResult = await client.callTool({
|
|
219
|
+
name: "config_set",
|
|
220
|
+
arguments: { key: "theme", value: "dark" },
|
|
221
|
+
});
|
|
222
|
+
expect(firstTextContent(configResult)).toBe("set theme = dark");
|
|
223
|
+
// ── Call nonexistent tool → MCP error ──
|
|
224
|
+
await expect(client.callTool({ name: "nonexistent", arguments: {} })).rejects.toThrow(/not found/i);
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
await client.close();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
it("returns empty tool list when only the mcp command exists", async () => {
|
|
231
|
+
const cli = goke("empty-app");
|
|
232
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
233
|
+
cli.command("mcp", "Start MCP server").action(createMcpAction({
|
|
234
|
+
cli,
|
|
235
|
+
createTransport: () => serverTransport,
|
|
236
|
+
}));
|
|
237
|
+
cli.matchedCommandName = "mcp";
|
|
238
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp");
|
|
239
|
+
await mcpCommand.commandAction({});
|
|
240
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
241
|
+
await client.connect(clientTransport);
|
|
242
|
+
try {
|
|
243
|
+
const tools = await client.listTools();
|
|
244
|
+
expect(tools.tools).toEqual([]);
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
await client.close();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
it("exposes all commands when matchedCommandName is not set", async () => {
|
|
251
|
+
const cli = goke("test");
|
|
252
|
+
cli.command("ping", "Ping").action(() => "pong");
|
|
253
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
254
|
+
cli.command("mcp", "Start MCP server").action(createMcpAction({
|
|
255
|
+
cli,
|
|
256
|
+
createTransport: () => serverTransport,
|
|
257
|
+
}));
|
|
258
|
+
// Do NOT set matchedCommandName — simulates programmatic invocation
|
|
259
|
+
// without cli.parse(). All commands including "mcp" should be exposed.
|
|
260
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp");
|
|
261
|
+
await mcpCommand.commandAction({});
|
|
262
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
263
|
+
await client.connect(clientTransport);
|
|
264
|
+
try {
|
|
265
|
+
const tools = await client.listTools();
|
|
266
|
+
const toolNames = tools.tools.map((t) => t.name).sort();
|
|
267
|
+
// Without matchedCommandName, auto-exclusion can't kick in
|
|
268
|
+
expect(toolNames).toEqual(["mcp", "ping"]);
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
await client.close();
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
});
|
package/dist/cli-to-mcp.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
8
|
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
9
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
9
10
|
import { type Goke } from "goke";
|
|
10
11
|
export interface AddCliToolsToMcpOptions {
|
|
11
12
|
cli: Goke;
|
|
@@ -13,5 +14,32 @@ export interface AddCliToolsToMcpOptions {
|
|
|
13
14
|
commandFilter?: (commandName: string) => boolean;
|
|
14
15
|
sanitizeToolName?: (commandName: string) => string;
|
|
15
16
|
}
|
|
17
|
+
export interface CreateMcpActionOptions {
|
|
18
|
+
/** The CLI instance whose commands will be exposed as MCP tools */
|
|
19
|
+
cli: Goke;
|
|
20
|
+
/** Additional filter for which commands to expose. The MCP command itself is always excluded. */
|
|
21
|
+
commandFilter?: (commandName: string) => boolean;
|
|
22
|
+
/** Custom tool name sanitizer */
|
|
23
|
+
sanitizeToolName?: (commandName: string) => string;
|
|
24
|
+
/** MCP server name. Defaults to the CLI name or 'cli-mcp-server' */
|
|
25
|
+
serverName?: string;
|
|
26
|
+
/** MCP server version. Defaults to '1.0.0' */
|
|
27
|
+
serverVersion?: string;
|
|
28
|
+
/** Custom transport factory. Defaults to StdioServerTransport (stdin/stdout). */
|
|
29
|
+
createTransport?: () => Transport | Promise<Transport>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Create a goke action callback that starts an MCP server over stdio.
|
|
33
|
+
*
|
|
34
|
+
* Exposes all CLI commands as MCP tools, automatically excluding the
|
|
35
|
+
* command this action is attached to.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* cli.command('mcp', 'Start MCP server over stdio')
|
|
40
|
+
* .action(createMcpAction({ cli }))
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export declare function createMcpAction(options: CreateMcpActionOptions): (...args: any[]) => Promise<void>;
|
|
16
44
|
export declare function addCliToolsToMcp(options: AddCliToolsToMcpOptions): void;
|
|
17
45
|
//# sourceMappingURL=cli-to-mcp.d.ts.map
|
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;
|
|
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,EAAmD,KAAK,IAAI,EAA6B,MAAM,MAAM,CAAC;AAwD7G,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;AA4XD,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
|
@@ -342,6 +342,50 @@ function getOrInstallState(server) {
|
|
|
342
342
|
});
|
|
343
343
|
return state;
|
|
344
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* Create a goke action callback that starts an MCP server over stdio.
|
|
347
|
+
*
|
|
348
|
+
* Exposes all CLI commands as MCP tools, automatically excluding the
|
|
349
|
+
* command this action is attached to.
|
|
350
|
+
*
|
|
351
|
+
* @example
|
|
352
|
+
* ```ts
|
|
353
|
+
* cli.command('mcp', 'Start MCP server over stdio')
|
|
354
|
+
* .action(createMcpAction({ cli }))
|
|
355
|
+
* ```
|
|
356
|
+
*/
|
|
357
|
+
export function createMcpAction(options) {
|
|
358
|
+
const { cli, commandFilter: userFilter, sanitizeToolName, serverName, serverVersion, createTransport } = options;
|
|
359
|
+
return async () => {
|
|
360
|
+
// At call time, goke has already matched the command and set matchedCommandName.
|
|
361
|
+
// We use it to auto-exclude the MCP command itself from the tool list.
|
|
362
|
+
const mcpCommandName = cli.matchedCommandName;
|
|
363
|
+
const { Server: ServerClass } = await import("@modelcontextprotocol/sdk/server/index.js");
|
|
364
|
+
const server = new ServerClass({
|
|
365
|
+
name: serverName || cli.name || "cli-mcp-server",
|
|
366
|
+
version: serverVersion || "1.0.0",
|
|
367
|
+
}, { capabilities: {} });
|
|
368
|
+
addCliToolsToMcp({
|
|
369
|
+
cli,
|
|
370
|
+
server,
|
|
371
|
+
commandFilter: (name) => {
|
|
372
|
+
if (mcpCommandName && name === mcpCommandName)
|
|
373
|
+
return false;
|
|
374
|
+
return userFilter ? userFilter(name) : true;
|
|
375
|
+
},
|
|
376
|
+
sanitizeToolName,
|
|
377
|
+
});
|
|
378
|
+
let transport;
|
|
379
|
+
if (createTransport) {
|
|
380
|
+
transport = await createTransport();
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
384
|
+
transport = new StdioServerTransport();
|
|
385
|
+
}
|
|
386
|
+
await server.connect(transport);
|
|
387
|
+
};
|
|
388
|
+
}
|
|
345
389
|
export function addCliToolsToMcp(options) {
|
|
346
390
|
const { cli, commandFilter, sanitizeToolName = defaultSanitizeToolName } = options;
|
|
347
391
|
const server = resolveServer(options.server);
|
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.9",
|
|
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.3.0"
|
|
55
55
|
},
|
|
56
56
|
"scripts": {
|
|
57
57
|
"clean": "rm -rf dist",
|
|
@@ -0,0 +1,353 @@
|
|
|
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: { name: string }) => `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: { a: number; b: number }) => ({ 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: { query: string; limit: number }) => {
|
|
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: string, options: { dryRun: boolean }) => {
|
|
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
|
+
cli
|
|
194
|
+
.command("config set", "Set a config value")
|
|
195
|
+
.option("--key <key>", wrapJsonSchema({ type: "string", description: "Config key" }))
|
|
196
|
+
.option("--value <value>", wrapJsonSchema({ type: "string", description: "Config value" }))
|
|
197
|
+
.action((options: { key: string; value: string }) => {
|
|
198
|
+
return `set ${options.key} = ${options.value}`;
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Commands without actions (should NOT appear as tools)
|
|
202
|
+
cli.command("no-action", "This has no action handler");
|
|
203
|
+
|
|
204
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
205
|
+
|
|
206
|
+
cli.command("mcp", "Start MCP server").action(
|
|
207
|
+
createMcpAction({
|
|
208
|
+
cli,
|
|
209
|
+
serverName: "my-app-mcp",
|
|
210
|
+
serverVersion: "2.5.0",
|
|
211
|
+
createTransport: () => serverTransport,
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
cli.matchedCommandName = "mcp";
|
|
216
|
+
|
|
217
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp")!;
|
|
218
|
+
await mcpCommand.commandAction!({});
|
|
219
|
+
|
|
220
|
+
const client = new Client({ name: "e2e-test-client", version: "1.0.0" }, { capabilities: {} });
|
|
221
|
+
await client.connect(clientTransport);
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
// ── Server info ──
|
|
225
|
+
const serverInfo = client.getServerVersion();
|
|
226
|
+
expect(serverInfo).toMatchObject({ name: "my-app-mcp", version: "2.5.0" });
|
|
227
|
+
|
|
228
|
+
// ── Tool discovery ──
|
|
229
|
+
const tools = await client.listTools();
|
|
230
|
+
const toolNames = tools.tools.map((t) => t.name).sort();
|
|
231
|
+
// "mcp" auto-excluded, "no-action" has no handler → not mounted
|
|
232
|
+
expect(toolNames).toEqual(["config_set", "deploy", "fail", "search", "status"]);
|
|
233
|
+
|
|
234
|
+
// ── Verify schemas are propagated ──
|
|
235
|
+
const searchTool = tools.tools.find((t) => t.name === "search")!;
|
|
236
|
+
expect(searchTool.description).toBe("Search for items");
|
|
237
|
+
expect(searchTool.inputSchema.properties).toHaveProperty("query");
|
|
238
|
+
expect(searchTool.inputSchema.properties).toHaveProperty("limit");
|
|
239
|
+
expect(searchTool.inputSchema.required).toEqual(["query"]);
|
|
240
|
+
|
|
241
|
+
const deployTool = tools.tools.find((t) => t.name === "deploy")!;
|
|
242
|
+
expect(deployTool.inputSchema.properties).toHaveProperty("env");
|
|
243
|
+
expect(deployTool.inputSchema.properties).toHaveProperty("dryRun");
|
|
244
|
+
expect(deployTool.inputSchema.required).toEqual(["env"]);
|
|
245
|
+
|
|
246
|
+
// ── Call tool with schema-based options ──
|
|
247
|
+
const searchResult = await client.callTool({
|
|
248
|
+
name: "search",
|
|
249
|
+
arguments: { query: "hello", limit: 5 },
|
|
250
|
+
});
|
|
251
|
+
expect(firstTextContent(searchResult)).toBe(
|
|
252
|
+
'{\n "results": [\n "result for \\"hello\\""\n ],\n "limit": 5\n}',
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// ── Call tool with positional args ──
|
|
256
|
+
const deployResult = await client.callTool({
|
|
257
|
+
name: "deploy",
|
|
258
|
+
arguments: { env: "production", dryRun: true },
|
|
259
|
+
});
|
|
260
|
+
expect(firstTextContent(deployResult)).toBe("dry-run deploy to production");
|
|
261
|
+
|
|
262
|
+
// ── Call tool that returns a raw CallToolResult ──
|
|
263
|
+
const statusResult = await client.callTool({
|
|
264
|
+
name: "status",
|
|
265
|
+
arguments: {},
|
|
266
|
+
});
|
|
267
|
+
expect(firstTextContent(statusResult)).toBe("all systems operational");
|
|
268
|
+
|
|
269
|
+
// ── Call tool that throws → error is caught and returned as isError ──
|
|
270
|
+
const failResult = await client.callTool({
|
|
271
|
+
name: "fail",
|
|
272
|
+
arguments: {},
|
|
273
|
+
});
|
|
274
|
+
expect(failResult.isError).toBe(true);
|
|
275
|
+
expect(firstTextContent(failResult)).toBe("something went wrong");
|
|
276
|
+
|
|
277
|
+
// ── Call tool with multi-word command name ──
|
|
278
|
+
const configResult = await client.callTool({
|
|
279
|
+
name: "config_set",
|
|
280
|
+
arguments: { key: "theme", value: "dark" },
|
|
281
|
+
});
|
|
282
|
+
expect(firstTextContent(configResult)).toBe("set theme = dark");
|
|
283
|
+
|
|
284
|
+
// ── Call nonexistent tool → MCP error ──
|
|
285
|
+
await expect(
|
|
286
|
+
client.callTool({ name: "nonexistent", arguments: {} }),
|
|
287
|
+
).rejects.toThrow(/not found/i);
|
|
288
|
+
} finally {
|
|
289
|
+
await client.close();
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("returns empty tool list when only the mcp command exists", async () => {
|
|
294
|
+
const cli = goke("empty-app");
|
|
295
|
+
|
|
296
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
297
|
+
|
|
298
|
+
cli.command("mcp", "Start MCP server").action(
|
|
299
|
+
createMcpAction({
|
|
300
|
+
cli,
|
|
301
|
+
createTransport: () => serverTransport,
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
cli.matchedCommandName = "mcp";
|
|
306
|
+
|
|
307
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp")!;
|
|
308
|
+
await mcpCommand.commandAction!({});
|
|
309
|
+
|
|
310
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
311
|
+
await client.connect(clientTransport);
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
const tools = await client.listTools();
|
|
315
|
+
expect(tools.tools).toEqual([]);
|
|
316
|
+
} finally {
|
|
317
|
+
await client.close();
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("exposes all commands when matchedCommandName is not set", async () => {
|
|
322
|
+
const cli = goke("test");
|
|
323
|
+
|
|
324
|
+
cli.command("ping", "Ping").action(() => "pong");
|
|
325
|
+
|
|
326
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
327
|
+
|
|
328
|
+
cli.command("mcp", "Start MCP server").action(
|
|
329
|
+
createMcpAction({
|
|
330
|
+
cli,
|
|
331
|
+
createTransport: () => serverTransport,
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
// Do NOT set matchedCommandName — simulates programmatic invocation
|
|
336
|
+
// without cli.parse(). All commands including "mcp" should be exposed.
|
|
337
|
+
|
|
338
|
+
const mcpCommand = cli.commands.find((c) => c.name === "mcp")!;
|
|
339
|
+
await mcpCommand.commandAction!({});
|
|
340
|
+
|
|
341
|
+
const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
|
|
342
|
+
await client.connect(clientTransport);
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const tools = await client.listTools();
|
|
346
|
+
const toolNames = tools.tools.map((t) => t.name).sort();
|
|
347
|
+
// Without matchedCommandName, auto-exclusion can't kick in
|
|
348
|
+
expect(toolNames).toEqual(["mcp", "ping"]);
|
|
349
|
+
} finally {
|
|
350
|
+
await client.close();
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
});
|
package/src/cli-to-mcp.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
9
|
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
10
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
10
11
|
import {
|
|
11
12
|
CallToolRequestSchema,
|
|
12
13
|
ErrorCode,
|
|
@@ -456,6 +457,73 @@ function getOrInstallState(server: Server): CliToMcpState {
|
|
|
456
457
|
return state;
|
|
457
458
|
}
|
|
458
459
|
|
|
460
|
+
export interface CreateMcpActionOptions {
|
|
461
|
+
/** The CLI instance whose commands will be exposed as MCP tools */
|
|
462
|
+
cli: Goke;
|
|
463
|
+
/** Additional filter for which commands to expose. The MCP command itself is always excluded. */
|
|
464
|
+
commandFilter?: (commandName: string) => boolean;
|
|
465
|
+
/** Custom tool name sanitizer */
|
|
466
|
+
sanitizeToolName?: (commandName: string) => string;
|
|
467
|
+
/** MCP server name. Defaults to the CLI name or 'cli-mcp-server' */
|
|
468
|
+
serverName?: string;
|
|
469
|
+
/** MCP server version. Defaults to '1.0.0' */
|
|
470
|
+
serverVersion?: string;
|
|
471
|
+
/** Custom transport factory. Defaults to StdioServerTransport (stdin/stdout). */
|
|
472
|
+
createTransport?: () => Transport | Promise<Transport>;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Create a goke action callback that starts an MCP server over stdio.
|
|
477
|
+
*
|
|
478
|
+
* Exposes all CLI commands as MCP tools, automatically excluding the
|
|
479
|
+
* command this action is attached to.
|
|
480
|
+
*
|
|
481
|
+
* @example
|
|
482
|
+
* ```ts
|
|
483
|
+
* cli.command('mcp', 'Start MCP server over stdio')
|
|
484
|
+
* .action(createMcpAction({ cli }))
|
|
485
|
+
* ```
|
|
486
|
+
*/
|
|
487
|
+
export function createMcpAction(options: CreateMcpActionOptions): (...args: any[]) => Promise<void> {
|
|
488
|
+
const { cli, commandFilter: userFilter, sanitizeToolName, serverName, serverVersion, createTransport } = options;
|
|
489
|
+
|
|
490
|
+
return async () => {
|
|
491
|
+
// At call time, goke has already matched the command and set matchedCommandName.
|
|
492
|
+
// We use it to auto-exclude the MCP command itself from the tool list.
|
|
493
|
+
const mcpCommandName = cli.matchedCommandName;
|
|
494
|
+
|
|
495
|
+
const { Server: ServerClass } = await import("@modelcontextprotocol/sdk/server/index.js");
|
|
496
|
+
|
|
497
|
+
const server = new ServerClass(
|
|
498
|
+
{
|
|
499
|
+
name: serverName || cli.name || "cli-mcp-server",
|
|
500
|
+
version: serverVersion || "1.0.0",
|
|
501
|
+
},
|
|
502
|
+
{ capabilities: {} },
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
addCliToolsToMcp({
|
|
506
|
+
cli,
|
|
507
|
+
server,
|
|
508
|
+
commandFilter: (name) => {
|
|
509
|
+
if (mcpCommandName && name === mcpCommandName) return false;
|
|
510
|
+
return userFilter ? userFilter(name) : true;
|
|
511
|
+
},
|
|
512
|
+
sanitizeToolName,
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
let transport: Transport;
|
|
516
|
+
if (createTransport) {
|
|
517
|
+
transport = await createTransport();
|
|
518
|
+
} else {
|
|
519
|
+
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
520
|
+
transport = new StdioServerTransport();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
await server.connect(transport);
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
459
527
|
export function addCliToolsToMcp(options: AddCliToolsToMcpOptions): void {
|
|
460
528
|
const { cli, commandFilter, sanitizeToolName = defaultSanitizeToolName } = options;
|
|
461
529
|
const server = resolveServer(options.server);
|
package/src/index.ts
CHANGED
|
@@ -49,8 +49,8 @@ import yaml from "js-yaml";
|
|
|
49
49
|
import { FileOAuthProvider } from "./oauth-provider.js";
|
|
50
50
|
import { startOAuthFlow, isAuthRequiredError } from "./auth.js";
|
|
51
51
|
import type { McpOAuthConfig, McpOAuthState } from "./types.js";
|
|
52
|
-
export { addCliToolsToMcp } from "./cli-to-mcp.js";
|
|
53
|
-
export type { AddCliToolsToMcpOptions } from "./cli-to-mcp.js";
|
|
52
|
+
export { addCliToolsToMcp, createMcpAction } from "./cli-to-mcp.js";
|
|
53
|
+
export type { AddCliToolsToMcpOptions, CreateMcpActionOptions } from "./cli-to-mcp.js";
|
|
54
54
|
|
|
55
55
|
// Public exports - only types that consumers need
|
|
56
56
|
export type { Transport };
|