Skip to content

Commit be72c83

Browse files
NomadcxxCopilot
andauthored
feat(mcp): MCP tool bridge with mcptool CLI for Shell-based MCP tool execution (#38)
* docs: add MCP tool bridge implementation plan (#38) * chore: add @modelcontextprotocol/sdk dependency Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(mcp): add config reader for MCP server entries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(mcp): add client manager for direct MCP server connections Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(mcp): add tool bridge for registering MCP tools via plugin hook Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(mcp): wire MCP tool bridge into plugin startup (#38) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(mcp): add integration test for full MCP bridge pipeline Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(mcp): await bridge init so tools are registered before plugin returns The fire-and-forget IIFE populated mcpToolEntries via Object.assign after the plugin had already returned a spread-copy of the (empty) object. MCP tools were never actually available to the model. Fix: await the MCP init directly in the plugin's async function body so tools are discovered and registered before the tool hook is returned. Also replaces the fragile toString()-based lazy-load check in McpClientManager with a simple null check. * feat(mcp): inject tool definitions into chat.params and system message The tool hook only registers execution handlers — it does not add tool definitions to the model's tool list. This commit: 1. Adds buildMcpToolDefinitions() to generate function-call format defs 2. Injects them into output.options.tools in chat.params 3. Updates experimental.chat.system.transform to include MCP tool names Verified: all 7 MCP tools now reach the proxy in the 33-tool request. The model sees them in tool definitions but the runtime's authoritative system prompt does not list them, so the model currently will not call them. This is a runtime limitation, not a plugin bug. * feat(mcp): add mcptool CLI for Shell-based MCP tool execution Root cause: the agent binary has 15 fixed internal tools and cannot call plugin-registered tools directly. Solution: mcptool CLI that the model invokes via Shell to call any configured MCP server tool. - Add src/cli/mcptool.ts (servers, tools, call subcommands) - Update buildAvailableToolsSystemMessage with Shell usage instructions - Add mcptool binary to package.json bin + build entries - Update tests for new system message format --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a3506c6 commit be72c83

File tree

12 files changed

+2249
-11
lines changed

12 files changed

+2249
-11
lines changed

docs/plans/2026-03-10-mcp-tool-bridge.md

Lines changed: 1178 additions & 0 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
"main": "dist/plugin-entry.js",
77
"module": "src/plugin-entry.ts",
88
"scripts": {
9-
"build": "bun build ./src/index.ts ./src/plugin-entry.ts ./src/cli/discover.ts ./src/cli/opencode-cursor.ts --outdir ./dist --target node",
10-
"dev": "bun build ./src/index.ts ./src/plugin-entry.ts ./src/cli/discover.ts ./src/cli/opencode-cursor.ts --outdir ./dist --target node --watch",
9+
"build": "bun build ./src/index.ts ./src/plugin-entry.ts ./src/cli/discover.ts ./src/cli/opencode-cursor.ts ./src/cli/mcptool.ts --outdir ./dist --target node",
10+
"dev": "bun build ./src/index.ts ./src/plugin-entry.ts ./src/cli/discover.ts ./src/cli/opencode-cursor.ts ./src/cli/mcptool.ts --outdir ./dist --target node --watch",
1111
"test": "bun test",
1212
"test:unit": "bun test tests/unit",
1313
"test:integration": "bun test tests/integration",
@@ -18,7 +18,8 @@
1818
},
1919
"bin": {
2020
"open-cursor": "dist/cli/opencode-cursor.js",
21-
"cursor-discover": "dist/cli/discover.js"
21+
"cursor-discover": "dist/cli/discover.js",
22+
"mcptool": "dist/cli/mcptool.js"
2223
},
2324
"exports": {
2425
".": {
@@ -37,9 +38,10 @@
3738
"src"
3839
],
3940
"dependencies": {
40-
"ai": "^6.0.55",
41+
"@modelcontextprotocol/sdk": "^1.12.0",
4142
"@opencode-ai/plugin": "1.1.53",
4243
"@opencode-ai/sdk": "1.1.53",
44+
"ai": "^6.0.55",
4345
"strip-ansi": "^7.1.0"
4446
},
4547
"devDependencies": {

src/cli/mcptool.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* mcptool — CLI for calling MCP server tools from the shell.
5+
*
6+
* Usage:
7+
* mcptool servers List configured MCP servers
8+
* mcptool tools [server] List tools (optionally filter by server)
9+
* mcptool call <server> <tool> [json-args] Call a tool
10+
*
11+
* Reads MCP server configuration from opencode.json (same config the plugin uses).
12+
*/
13+
14+
import { readMcpConfigs } from "../mcp/config.js";
15+
import { McpClientManager } from "../mcp/client-manager.js";
16+
17+
const USAGE = `mcptool — call MCP server tools from the shell
18+
19+
Usage:
20+
mcptool servers List configured servers
21+
mcptool tools [server] List available tools
22+
mcptool call <server> <tool> [json-args] Call a tool
23+
24+
Examples:
25+
mcptool servers
26+
mcptool tools
27+
mcptool tools hybrid-memory
28+
mcptool call hybrid-memory memory_stats
29+
mcptool call hybrid-memory memory_search '{"query":"auth"}'
30+
mcptool call test-filesystem list_directory '{"path":"/tmp"}'`;
31+
32+
async function main(): Promise<void> {
33+
const args = process.argv.slice(2);
34+
35+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
36+
console.log(USAGE);
37+
process.exit(0);
38+
}
39+
40+
const command = args[0];
41+
const configs = readMcpConfigs();
42+
43+
if (configs.length === 0) {
44+
console.error("No MCP servers configured in opencode.json");
45+
process.exit(1);
46+
}
47+
48+
const manager = new McpClientManager();
49+
50+
if (command === "servers") {
51+
for (const c of configs) {
52+
const detail =
53+
c.type === "local" ? c.command.join(" ") : (c as any).url ?? "";
54+
console.log(`${c.name} (${c.type}) ${detail}`);
55+
}
56+
process.exit(0);
57+
}
58+
59+
if (command === "tools") {
60+
const filter = args[1];
61+
const toConnect = filter
62+
? configs.filter((c) => c.name === filter)
63+
: configs;
64+
65+
if (filter && toConnect.length === 0) {
66+
console.error(`Unknown server: ${filter}`);
67+
console.error(`Available: ${configs.map((c) => c.name).join(", ")}`);
68+
process.exit(1);
69+
}
70+
71+
await Promise.allSettled(toConnect.map((c) => manager.connectServer(c)));
72+
const tools = manager.listTools();
73+
74+
if (tools.length === 0) {
75+
console.log("No tools discovered.");
76+
} else {
77+
for (const t of tools) {
78+
const params = t.inputSchema
79+
? Object.keys((t.inputSchema as any).properties ?? {}).join(", ")
80+
: "";
81+
console.log(`${t.serverName}/${t.name} ${t.description ?? ""}`);
82+
if (params) console.log(` params: ${params}`);
83+
}
84+
}
85+
86+
await manager.disconnectAll();
87+
process.exit(0);
88+
}
89+
90+
if (command === "call") {
91+
const serverName = args[1];
92+
const toolName = args[2];
93+
const rawArgs = args[3];
94+
95+
if (!serverName || !toolName) {
96+
console.error("Usage: mcptool call <server> <tool> [json-args]");
97+
process.exit(1);
98+
}
99+
100+
const config = configs.find((c) => c.name === serverName);
101+
if (!config) {
102+
console.error(`Unknown server: ${serverName}`);
103+
console.error(`Available: ${configs.map((c) => c.name).join(", ")}`);
104+
process.exit(1);
105+
}
106+
107+
let toolArgs: Record<string, unknown> = {};
108+
if (rawArgs) {
109+
try {
110+
toolArgs = JSON.parse(rawArgs);
111+
} catch {
112+
console.error(`Invalid JSON args: ${rawArgs}`);
113+
process.exit(1);
114+
}
115+
}
116+
117+
await manager.connectServer(config);
118+
const result = await manager.callTool(serverName, toolName, toolArgs);
119+
console.log(result);
120+
121+
await manager.disconnectAll();
122+
process.exit(0);
123+
}
124+
125+
console.error(`Unknown command: ${command}`);
126+
console.log(USAGE);
127+
process.exit(1);
128+
}
129+
130+
main().catch((err) => {
131+
console.error(`mcptool error: ${err.message || err}`);
132+
process.exit(1);
133+
});

src/mcp/client-manager.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { createLogger } from "../utils/logger.js";
2+
import type { McpServerConfig } from "./config.js";
3+
4+
const log = createLogger("mcp:client-manager");
5+
6+
export interface McpToolInfo {
7+
name: string;
8+
description?: string;
9+
inputSchema?: Record<string, unknown>;
10+
}
11+
12+
interface DiscoveredTool extends McpToolInfo {
13+
serverName: string;
14+
}
15+
16+
interface ServerConnection {
17+
client: any;
18+
tools: McpToolInfo[];
19+
}
20+
21+
interface McpClientManagerDeps {
22+
createClient: () => any;
23+
createTransport: (config: McpServerConfig) => any;
24+
}
25+
26+
let defaultDeps: McpClientManagerDeps | null = null;
27+
28+
async function loadDefaultDeps(): Promise<McpClientManagerDeps> {
29+
if (defaultDeps) return defaultDeps;
30+
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
31+
const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
32+
33+
defaultDeps = {
34+
createClient: () =>
35+
new Client({ name: "open-cursor", version: "1.0.0" }, { capabilities: {} }),
36+
createTransport: (config: McpServerConfig) => {
37+
if (config.type === "local") {
38+
return new StdioClientTransport({
39+
command: config.command[0],
40+
args: config.command.slice(1),
41+
env: { ...process.env, ...(config.environment ?? {}) },
42+
stderr: "pipe",
43+
});
44+
}
45+
// Remote servers: StreamableHTTPClientTransport can be added later.
46+
throw new Error(`Remote MCP transport not yet implemented for ${config.name}`);
47+
},
48+
};
49+
return defaultDeps;
50+
}
51+
52+
export class McpClientManager {
53+
private connections = new Map<string, ServerConnection>();
54+
private deps: McpClientManagerDeps | null;
55+
56+
constructor(deps?: McpClientManagerDeps) {
57+
this.deps = deps ?? null;
58+
}
59+
60+
async connectServer(config: McpServerConfig): Promise<void> {
61+
if (this.connections.has(config.name)) {
62+
log.debug("Server already connected, skipping", { server: config.name });
63+
return;
64+
}
65+
66+
// Lazy-load MCP SDK if no deps were injected
67+
if (!this.deps) {
68+
try {
69+
this.deps = await loadDefaultDeps();
70+
} catch (err) {
71+
log.warn("Failed to load MCP SDK", { error: String(err) });
72+
return;
73+
}
74+
}
75+
76+
const deps = this.deps;
77+
let client: any;
78+
try {
79+
client = deps.createClient();
80+
const transport = deps.createTransport(config);
81+
await client.connect(transport);
82+
} catch (err) {
83+
log.warn("MCP server connection failed", {
84+
server: config.name,
85+
error: String(err),
86+
});
87+
return;
88+
}
89+
90+
let tools: McpToolInfo[] = [];
91+
try {
92+
const result = await client.listTools();
93+
tools = result?.tools ?? [];
94+
log.info("MCP server connected", {
95+
server: config.name,
96+
tools: tools.length,
97+
});
98+
} catch (err) {
99+
log.warn("MCP tool discovery failed", {
100+
server: config.name,
101+
error: String(err),
102+
});
103+
}
104+
105+
this.connections.set(config.name, { client, tools });
106+
}
107+
108+
listTools(): DiscoveredTool[] {
109+
const all: DiscoveredTool[] = [];
110+
for (const [serverName, conn] of this.connections) {
111+
for (const tool of conn.tools) {
112+
all.push({ ...tool, serverName });
113+
}
114+
}
115+
return all;
116+
}
117+
118+
async callTool(
119+
serverName: string,
120+
toolName: string,
121+
args: Record<string, unknown>,
122+
): Promise<string> {
123+
const conn = this.connections.get(serverName);
124+
if (!conn) {
125+
return `Error: MCP server "${serverName}" not connected`;
126+
}
127+
128+
try {
129+
const result = await conn.client.callTool({
130+
name: toolName,
131+
arguments: args,
132+
});
133+
134+
// MCP callTool returns { content: Array<{ type, text }> }
135+
if (Array.isArray(result?.content)) {
136+
return result.content
137+
.map((c: any) => (c.type === "text" ? c.text : JSON.stringify(c)))
138+
.join("\n");
139+
}
140+
return typeof result === "string" ? result : JSON.stringify(result);
141+
} catch (err: any) {
142+
log.warn("MCP tool call failed", {
143+
server: serverName,
144+
tool: toolName,
145+
error: String(err?.message || err),
146+
});
147+
return `Error: MCP tool "${toolName}" failed: ${err?.message || err}`;
148+
}
149+
}
150+
151+
async disconnectAll(): Promise<void> {
152+
for (const [name, conn] of this.connections) {
153+
try {
154+
await conn.client.close();
155+
log.debug("MCP server disconnected", { server: name });
156+
} catch (err) {
157+
log.debug("MCP server disconnect failed", { server: name, error: String(err) });
158+
}
159+
}
160+
this.connections.clear();
161+
}
162+
163+
get connectedServers(): string[] {
164+
return Array.from(this.connections.keys());
165+
}
166+
}

0 commit comments

Comments
 (0)