@sammorrowdrums/mcpi-ext 0.1.0
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/.dockerignore +6 -0
- package/.github/dependabot.yml +25 -0
- package/.github/workflows/ci.yml +50 -0
- package/.hadolint.yaml +6 -0
- package/.prettierignore +4 -0
- package/.prettierrc +7 -0
- package/AGENTS.md +118 -0
- package/DECISIONS.md +99 -0
- package/Dockerfile +39 -0
- package/README.md +278 -0
- package/aube-lock.yaml +4146 -0
- package/dist/code-mode/code-mode-integration.test.d.ts +2 -0
- package/dist/code-mode/code-mode-integration.test.d.ts.map +1 -0
- package/dist/code-mode/code-mode-integration.test.js +116 -0
- package/dist/code-mode/code-mode-integration.test.js.map +1 -0
- package/dist/code-mode/eligibility.d.ts +10 -0
- package/dist/code-mode/eligibility.d.ts.map +1 -0
- package/dist/code-mode/eligibility.js +14 -0
- package/dist/code-mode/eligibility.js.map +1 -0
- package/dist/code-mode/eligibility.test.d.ts +2 -0
- package/dist/code-mode/eligibility.test.d.ts.map +1 -0
- package/dist/code-mode/eligibility.test.js +53 -0
- package/dist/code-mode/eligibility.test.js.map +1 -0
- package/dist/code-mode/executor.d.ts +35 -0
- package/dist/code-mode/executor.d.ts.map +1 -0
- package/dist/code-mode/executor.js +120 -0
- package/dist/code-mode/executor.js.map +1 -0
- package/dist/code-mode/executor.test.d.ts +2 -0
- package/dist/code-mode/executor.test.d.ts.map +1 -0
- package/dist/code-mode/executor.test.js +130 -0
- package/dist/code-mode/executor.test.js.map +1 -0
- package/dist/code-mode/index.d.ts +65 -0
- package/dist/code-mode/index.d.ts.map +1 -0
- package/dist/code-mode/index.js +175 -0
- package/dist/code-mode/index.js.map +1 -0
- package/dist/code-mode/tools.d.ts +45 -0
- package/dist/code-mode/tools.d.ts.map +1 -0
- package/dist/code-mode/tools.js +70 -0
- package/dist/code-mode/tools.js.map +1 -0
- package/dist/code-mode/type-hints.d.ts +20 -0
- package/dist/code-mode/type-hints.d.ts.map +1 -0
- package/dist/code-mode/type-hints.js +189 -0
- package/dist/code-mode/type-hints.js.map +1 -0
- package/dist/code-mode/type-hints.test.d.ts +2 -0
- package/dist/code-mode/type-hints.test.d.ts.map +1 -0
- package/dist/code-mode/type-hints.test.js +157 -0
- package/dist/code-mode/type-hints.test.js.map +1 -0
- package/dist/docker-e2e.d.ts +55 -0
- package/dist/docker-e2e.d.ts.map +1 -0
- package/dist/docker-e2e.js +235 -0
- package/dist/docker-e2e.js.map +1 -0
- package/dist/docker-e2e.test.d.ts +2 -0
- package/dist/docker-e2e.test.d.ts.map +1 -0
- package/dist/docker-e2e.test.js +176 -0
- package/dist/docker-e2e.test.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +128 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +8 -0
- package/dist/index.test.js.map +1 -0
- package/dist/mcp/client-manager.d.ts +46 -0
- package/dist/mcp/client-manager.d.ts.map +1 -0
- package/dist/mcp/client-manager.js +148 -0
- package/dist/mcp/client-manager.js.map +1 -0
- package/dist/mcp/client-manager.test.d.ts +2 -0
- package/dist/mcp/client-manager.test.d.ts.map +1 -0
- package/dist/mcp/client-manager.test.js +204 -0
- package/dist/mcp/client-manager.test.js.map +1 -0
- package/dist/mcp/config-loader.d.ts +8 -0
- package/dist/mcp/config-loader.d.ts.map +1 -0
- package/dist/mcp/config-loader.js +43 -0
- package/dist/mcp/config-loader.js.map +1 -0
- package/dist/mcp/config-loader.test.d.ts +2 -0
- package/dist/mcp/config-loader.test.d.ts.map +1 -0
- package/dist/mcp/config-loader.test.js +93 -0
- package/dist/mcp/config-loader.test.js.map +1 -0
- package/dist/mcp/config.d.ts +46 -0
- package/dist/mcp/config.d.ts.map +1 -0
- package/dist/mcp/config.js +28 -0
- package/dist/mcp/config.js.map +1 -0
- package/dist/mcp/index.d.ts +5 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/skills/discover.d.ts +10 -0
- package/dist/skills/discover.d.ts.map +1 -0
- package/dist/skills/discover.js +60 -0
- package/dist/skills/discover.js.map +1 -0
- package/dist/skills/format.d.ts +10 -0
- package/dist/skills/format.d.ts.map +1 -0
- package/dist/skills/format.js +38 -0
- package/dist/skills/format.js.map +1 -0
- package/dist/skills/format.test.d.ts +2 -0
- package/dist/skills/format.test.d.ts.map +1 -0
- package/dist/skills/format.test.js +63 -0
- package/dist/skills/format.test.js.map +1 -0
- package/dist/skills/index.d.ts +6 -0
- package/dist/skills/index.d.ts.map +1 -0
- package/dist/skills/index.js +6 -0
- package/dist/skills/index.js.map +1 -0
- package/dist/skills/load-skill-tool.d.ts +40 -0
- package/dist/skills/load-skill-tool.d.ts.map +1 -0
- package/dist/skills/load-skill-tool.js +104 -0
- package/dist/skills/load-skill-tool.js.map +1 -0
- package/dist/skills/mcp-tool-proxy.d.ts +11 -0
- package/dist/skills/mcp-tool-proxy.d.ts.map +1 -0
- package/dist/skills/mcp-tool-proxy.js +116 -0
- package/dist/skills/mcp-tool-proxy.js.map +1 -0
- package/dist/skills/skill-integration.test.d.ts +2 -0
- package/dist/skills/skill-integration.test.d.ts.map +1 -0
- package/dist/skills/skill-integration.test.js +119 -0
- package/dist/skills/skill-integration.test.js.map +1 -0
- package/dist/skills/skill-registry.d.ts +39 -0
- package/dist/skills/skill-registry.d.ts.map +1 -0
- package/dist/skills/skill-registry.js +48 -0
- package/dist/skills/skill-registry.js.map +1 -0
- package/dist/skills/skill-registry.test.d.ts +2 -0
- package/dist/skills/skill-registry.test.d.ts.map +1 -0
- package/dist/skills/skill-registry.test.js +67 -0
- package/dist/skills/skill-registry.test.js.map +1 -0
- package/dist/test-servers/weather-server.d.ts +12 -0
- package/dist/test-servers/weather-server.d.ts.map +1 -0
- package/dist/test-servers/weather-server.js +139 -0
- package/dist/test-servers/weather-server.js.map +1 -0
- package/dist/test-servers/weather-stdio.d.ts +2 -0
- package/dist/test-servers/weather-stdio.d.ts.map +1 -0
- package/dist/test-servers/weather-stdio.js +11 -0
- package/dist/test-servers/weather-stdio.js.map +1 -0
- package/dist/tool-cli/cli.d.ts +3 -0
- package/dist/tool-cli/cli.d.ts.map +1 -0
- package/dist/tool-cli/cli.js +179 -0
- package/dist/tool-cli/cli.js.map +1 -0
- package/dist/tool-cli/constants.d.ts +7 -0
- package/dist/tool-cli/constants.d.ts.map +1 -0
- package/dist/tool-cli/constants.js +15 -0
- package/dist/tool-cli/constants.js.map +1 -0
- package/dist/tool-cli/format.d.ts +8 -0
- package/dist/tool-cli/format.d.ts.map +1 -0
- package/dist/tool-cli/format.js +57 -0
- package/dist/tool-cli/format.js.map +1 -0
- package/dist/tool-cli/format.test.d.ts +2 -0
- package/dist/tool-cli/format.test.d.ts.map +1 -0
- package/dist/tool-cli/format.test.js +30 -0
- package/dist/tool-cli/format.test.js.map +1 -0
- package/dist/tool-cli/index.d.ts +4 -0
- package/dist/tool-cli/index.d.ts.map +1 -0
- package/dist/tool-cli/index.js +4 -0
- package/dist/tool-cli/index.js.map +1 -0
- package/dist/tool-cli/rpc-client.d.ts +6 -0
- package/dist/tool-cli/rpc-client.d.ts.map +1 -0
- package/dist/tool-cli/rpc-client.js +25 -0
- package/dist/tool-cli/rpc-client.js.map +1 -0
- package/dist/tool-cli/rpc-server.d.ts +48 -0
- package/dist/tool-cli/rpc-server.d.ts.map +1 -0
- package/dist/tool-cli/rpc-server.js +220 -0
- package/dist/tool-cli/rpc-server.js.map +1 -0
- package/dist/tool-cli/rpc-server.test.d.ts +2 -0
- package/dist/tool-cli/rpc-server.test.d.ts.map +1 -0
- package/dist/tool-cli/rpc-server.test.js +154 -0
- package/dist/tool-cli/rpc-server.test.js.map +1 -0
- package/eslint.config.js +27 -0
- package/images/banner.webp +0 -0
- package/images/code-c-maude.webp +0 -0
- package/images/nuclear-mcp-football.webp +0 -0
- package/images/the-skill-dealer.webp +0 -0
- package/mise.toml +65 -0
- package/package.json +46 -0
- package/scripts/docker-entrypoint.sh +29 -0
- package/scripts/test-echo-server.mjs +28 -0
- package/scripts/test-mcp-integration.mjs +59 -0
- package/scripts/test-tool-cli-smoke.mjs +62 -0
- package/src/code-mode/code-mode-integration.test.ts +135 -0
- package/src/code-mode/eligibility.test.ts +60 -0
- package/src/code-mode/eligibility.ts +16 -0
- package/src/code-mode/executor.test.ts +168 -0
- package/src/code-mode/executor.ts +163 -0
- package/src/code-mode/index.ts +208 -0
- package/src/code-mode/tools.ts +110 -0
- package/src/code-mode/type-hints.test.ts +187 -0
- package/src/code-mode/type-hints.ts +249 -0
- package/src/docker-e2e.test.ts +195 -0
- package/src/docker-e2e.ts +277 -0
- package/src/index.test.ts +8 -0
- package/src/index.ts +158 -0
- package/src/mcp/client-manager.test.ts +250 -0
- package/src/mcp/client-manager.ts +199 -0
- package/src/mcp/config-loader.test.ts +115 -0
- package/src/mcp/config-loader.ts +46 -0
- package/src/mcp/config.ts +40 -0
- package/src/mcp/index.ts +4 -0
- package/src/skills/discover.ts +81 -0
- package/src/skills/format.test.ts +70 -0
- package/src/skills/format.ts +42 -0
- package/src/skills/index.ts +9 -0
- package/src/skills/load-skill-tool.ts +136 -0
- package/src/skills/mcp-tool-proxy.ts +143 -0
- package/src/skills/skill-integration.test.ts +139 -0
- package/src/skills/skill-registry.test.ts +83 -0
- package/src/skills/skill-registry.ts +69 -0
- package/src/test-servers/weather-server.ts +171 -0
- package/src/test-servers/weather-stdio.ts +11 -0
- package/src/tool-cli/cli.ts +232 -0
- package/src/tool-cli/constants.ts +15 -0
- package/src/tool-cli/format.test.ts +33 -0
- package/src/tool-cli/format.ts +56 -0
- package/src/tool-cli/index.ts +3 -0
- package/src/tool-cli/rpc-client.ts +39 -0
- package/src/tool-cli/rpc-server.test.ts +193 -0
- package/src/tool-cli/rpc-server.ts +287 -0
- package/tsconfig.json +21 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BeforeAgentStartEvent,
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
SessionStartEvent,
|
|
6
|
+
ToolCallEvent,
|
|
7
|
+
} from "@sammorrowdrums/mcpi";
|
|
8
|
+
import { CodeModeManager } from "./code-mode/index.js";
|
|
9
|
+
import { dockerE2ETool } from "./docker-e2e.js";
|
|
10
|
+
import { McpClientManager, loadMcpConfig } from "./mcp/index.js";
|
|
11
|
+
import {
|
|
12
|
+
SkillRegistry,
|
|
13
|
+
createLoadSkillTool,
|
|
14
|
+
discoverSkillsFromServer,
|
|
15
|
+
formatMcpSkillsForPrompt,
|
|
16
|
+
registerMcpToolProxies,
|
|
17
|
+
} from "./skills/index.js";
|
|
18
|
+
import { ToolCliRpcServer, formatToolCliForPrompt } from "./tool-cli/index.js";
|
|
19
|
+
|
|
20
|
+
export default function (pi: ExtensionAPI) {
|
|
21
|
+
pi.registerTool(dockerE2ETool);
|
|
22
|
+
|
|
23
|
+
pi.registerFlag("mcp-config", {
|
|
24
|
+
description: "Path to MCP server configuration JSON file",
|
|
25
|
+
type: "string",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const mcpManager = new McpClientManager();
|
|
29
|
+
const skillRegistry = new SkillRegistry();
|
|
30
|
+
const rpcServer = new ToolCliRpcServer(mcpManager);
|
|
31
|
+
const codeModeManager = new CodeModeManager();
|
|
32
|
+
const enabledTools = new Set<string>();
|
|
33
|
+
const gatedToolNames = new Set<string>();
|
|
34
|
+
|
|
35
|
+
// Register the load_skill tool so the model can activate MCP skills
|
|
36
|
+
pi.registerTool(createLoadSkillTool({ registry: skillRegistry, mcpManager, enabledTools }));
|
|
37
|
+
|
|
38
|
+
pi.on("session_start", async (_event: SessionStartEvent, ctx: ExtensionContext) => {
|
|
39
|
+
if (ctx.hasUI) {
|
|
40
|
+
ctx.ui.notify("mcpi-ext loaded", "info");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const configPath = pi.getFlag("mcp-config") as string | undefined;
|
|
44
|
+
const log = (msg: string) => {
|
|
45
|
+
if (ctx.hasUI) {
|
|
46
|
+
ctx.ui.notify(msg, "info");
|
|
47
|
+
} else {
|
|
48
|
+
console.error(msg);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
try {
|
|
52
|
+
const config = await loadMcpConfig(configPath);
|
|
53
|
+
const serverCount = Object.keys(config.mcpServers).length;
|
|
54
|
+
if (serverCount > 0) {
|
|
55
|
+
await mcpManager.connectAll(config, log);
|
|
56
|
+
const tools = mcpManager.getTools();
|
|
57
|
+
log(
|
|
58
|
+
`MCP: ${mcpManager.getConnectedServers().length} server(s), ${tools.length} tool(s) discovered`,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Pre-register all MCP tools as deferred Pi tool proxies
|
|
62
|
+
// (in tools array for dispatch but excluded from system prompt)
|
|
63
|
+
const allToolNames = tools.map((t) => t.name);
|
|
64
|
+
registerMcpToolProxies(allToolNames, mcpManager, pi);
|
|
65
|
+
|
|
66
|
+
// Discover skills from all connected servers
|
|
67
|
+
for (const serverName of mcpManager.getConnectedServers()) {
|
|
68
|
+
const client = mcpManager.getClient(serverName);
|
|
69
|
+
if (!client) continue;
|
|
70
|
+
try {
|
|
71
|
+
const skills = await discoverSkillsFromServer(client, serverName, log);
|
|
72
|
+
skillRegistry.registerAll(skills);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
log(
|
|
75
|
+
`[skills] Failed to discover skills from "${serverName}": ${(err as Error).message}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (skillRegistry.size > 0) {
|
|
81
|
+
for (const skill of skillRegistry.getAll()) {
|
|
82
|
+
for (const t of skill.allowedTools) {
|
|
83
|
+
gatedToolNames.add(t);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
log(
|
|
87
|
+
`MCP: ${skillRegistry.size} skill(s) discovered, ${gatedToolNames.size} tool(s) deferred`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Start the tool-cli RPC server for progressive tool discovery
|
|
92
|
+
try {
|
|
93
|
+
await rpcServer.start(log);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
log(`[tool-cli] Failed to start RPC server: ${(err as Error).message}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Initialize code mode (Tier 3) for read-only tools with structured output
|
|
99
|
+
codeModeManager.initialize(mcpManager);
|
|
100
|
+
if (codeModeManager.isActive) {
|
|
101
|
+
const { codeSearch, codeExecute } = codeModeManager.createTools();
|
|
102
|
+
pi.registerTool(codeSearch);
|
|
103
|
+
pi.registerTool(codeExecute);
|
|
104
|
+
log(
|
|
105
|
+
`MCP: Code mode active (${codeModeManager.getEligibleTools().length} eligible tool(s))`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} catch (err) {
|
|
110
|
+
const msg = `MCP config error: ${(err as Error).message}`;
|
|
111
|
+
if (ctx.hasUI) {
|
|
112
|
+
ctx.ui.notify(msg, "warning");
|
|
113
|
+
} else {
|
|
114
|
+
console.error(msg);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Inject MCP skills, tool-cli advice, and code mode type hints into the system prompt
|
|
120
|
+
pi.on("before_agent_start", async (event: BeforeAgentStartEvent) => {
|
|
121
|
+
let extra = "";
|
|
122
|
+
|
|
123
|
+
const skills = skillRegistry.getAll();
|
|
124
|
+
if (skills.length > 0) {
|
|
125
|
+
extra += formatMcpSkillsForPrompt(skills);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const serverCount = mcpManager.getConnectedServers().length;
|
|
129
|
+
extra += formatToolCliForPrompt(serverCount);
|
|
130
|
+
|
|
131
|
+
if (codeModeManager.isActive) {
|
|
132
|
+
extra += codeModeManager.formatSystemPromptSection();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (extra.length === 0) return;
|
|
136
|
+
return { systemPrompt: event.systemPrompt + extra };
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Block deferred MCP tools until their skill is loaded
|
|
140
|
+
pi.on("tool_call", async (event: ToolCallEvent) => {
|
|
141
|
+
const name = "toolName" in event ? event.toolName : undefined;
|
|
142
|
+
if (!name || !gatedToolNames.has(name) || enabledTools.has(name)) return;
|
|
143
|
+
const relevantSkills = skillRegistry
|
|
144
|
+
.getAll()
|
|
145
|
+
.filter((s) => s.allowedTools.includes(name))
|
|
146
|
+
.map((s) => s.name);
|
|
147
|
+
return {
|
|
148
|
+
block: true,
|
|
149
|
+
reason: `Tool "${name}" requires loading a skill first. Call load_skill with one of: ${relevantSkills.join(", ")}`,
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
pi.on("session_shutdown", async () => {
|
|
154
|
+
await rpcServer.stop();
|
|
155
|
+
await mcpManager.disconnectAll();
|
|
156
|
+
skillRegistry.clear();
|
|
157
|
+
});
|
|
158
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { McpClientManager } from "./client-manager.js";
|
|
3
|
+
|
|
4
|
+
// Mock the MCP SDK transports and client
|
|
5
|
+
vi.mock("@modelcontextprotocol/sdk/client/index.js", () => {
|
|
6
|
+
class MockClient {
|
|
7
|
+
private _onToolsChanged?: (error: Error | null, tools: unknown[] | null) => void;
|
|
8
|
+
|
|
9
|
+
constructor(
|
|
10
|
+
_info: unknown,
|
|
11
|
+
options?: {
|
|
12
|
+
listChanged?: {
|
|
13
|
+
tools?: { onChanged?: (error: Error | null, tools: unknown[] | null) => void };
|
|
14
|
+
};
|
|
15
|
+
},
|
|
16
|
+
) {
|
|
17
|
+
this._onToolsChanged = options?.listChanged?.tools?.onChanged;
|
|
18
|
+
}
|
|
19
|
+
async connect(_transport: unknown): Promise<void> {
|
|
20
|
+
// no-op for tests
|
|
21
|
+
}
|
|
22
|
+
async close(): Promise<void> {
|
|
23
|
+
// no-op for tests
|
|
24
|
+
}
|
|
25
|
+
async listTools(): Promise<{ tools: unknown[] }> {
|
|
26
|
+
return {
|
|
27
|
+
tools: [
|
|
28
|
+
{
|
|
29
|
+
name: "mock_tool",
|
|
30
|
+
description: "A mock tool for testing",
|
|
31
|
+
inputSchema: { type: "object", properties: { arg: { type: "string" } } },
|
|
32
|
+
annotations: { readOnlyHint: true },
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "mock_tool_2",
|
|
36
|
+
description: "Another mock tool",
|
|
37
|
+
inputSchema: { type: "object", properties: {} },
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Test helper to simulate tools/list_changed notification. */
|
|
44
|
+
_simulateToolsChanged(tools: unknown[]): void {
|
|
45
|
+
this._onToolsChanged?.(null, tools);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { Client: MockClient };
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
|
|
53
|
+
StdioClientTransport: class {
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
|
|
55
|
+
constructor(_params: unknown) {
|
|
56
|
+
// no-op mock
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
|
|
63
|
+
StreamableHTTPClientTransport: class {
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
|
|
65
|
+
constructor(_url: URL, _opts?: unknown) {
|
|
66
|
+
// no-op mock
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
describe("McpClientManager", () => {
|
|
72
|
+
let manager: McpClientManager;
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
manager = new McpClientManager();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("connects to a stdio server and discovers tools", async () => {
|
|
79
|
+
await manager.connectAll({
|
|
80
|
+
mcpServers: {
|
|
81
|
+
"test-stdio": {
|
|
82
|
+
type: "stdio",
|
|
83
|
+
command: "node",
|
|
84
|
+
args: ["test-server.js"],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(manager.getConnectedServers()).toEqual(["test-stdio"]);
|
|
90
|
+
|
|
91
|
+
const tools = manager.getTools();
|
|
92
|
+
expect(tools).toHaveLength(2);
|
|
93
|
+
expect(tools[0].name).toBe("mock_tool");
|
|
94
|
+
expect(tools[0].serverName).toBe("test-stdio");
|
|
95
|
+
expect(tools[0].description).toBe("A mock tool for testing");
|
|
96
|
+
expect(tools[0].annotations).toEqual({ readOnlyHint: true });
|
|
97
|
+
expect(tools[1].name).toBe("mock_tool_2");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("connects to a remote server and discovers tools", async () => {
|
|
101
|
+
await manager.connectAll({
|
|
102
|
+
mcpServers: {
|
|
103
|
+
"test-remote": {
|
|
104
|
+
type: "remote",
|
|
105
|
+
url: "https://example.com/mcp",
|
|
106
|
+
headers: { Authorization: "Bearer test" },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(manager.getConnectedServers()).toEqual(["test-remote"]);
|
|
112
|
+
expect(manager.getTools()).toHaveLength(2);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("connects to multiple servers and aggregates tools", async () => {
|
|
116
|
+
await manager.connectAll({
|
|
117
|
+
mcpServers: {
|
|
118
|
+
server1: { type: "stdio", command: "node", args: ["s1.js"] },
|
|
119
|
+
server2: { type: "remote", url: "https://example.com/mcp" },
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(manager.getConnectedServers()).toHaveLength(2);
|
|
124
|
+
// 2 tools per server
|
|
125
|
+
expect(manager.getTools()).toHaveLength(4);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("getToolsForServer returns tools for a specific server", async () => {
|
|
129
|
+
await manager.connectAll({
|
|
130
|
+
mcpServers: {
|
|
131
|
+
srv: { type: "stdio", command: "echo" },
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(manager.getToolsForServer("srv")).toHaveLength(2);
|
|
136
|
+
expect(manager.getToolsForServer("nonexistent")).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("getClient returns the client for a connected server", async () => {
|
|
140
|
+
await manager.connectAll({
|
|
141
|
+
mcpServers: {
|
|
142
|
+
srv: { type: "stdio", command: "echo" },
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(manager.getClient("srv")).toBeDefined();
|
|
147
|
+
expect(manager.getClient("nonexistent")).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("disconnectOne removes a server", async () => {
|
|
151
|
+
await manager.connectAll({
|
|
152
|
+
mcpServers: {
|
|
153
|
+
srv: { type: "stdio", command: "echo" },
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(manager.getConnectedServers()).toEqual(["srv"]);
|
|
158
|
+
await manager.disconnectOne("srv");
|
|
159
|
+
expect(manager.getConnectedServers()).toEqual([]);
|
|
160
|
+
expect(manager.getTools()).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("disconnectAll removes all servers", async () => {
|
|
164
|
+
await manager.connectAll({
|
|
165
|
+
mcpServers: {
|
|
166
|
+
s1: { type: "stdio", command: "echo" },
|
|
167
|
+
s2: { type: "remote", url: "https://example.com/mcp" },
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(manager.getConnectedServers()).toHaveLength(2);
|
|
172
|
+
await manager.disconnectAll();
|
|
173
|
+
expect(manager.getConnectedServers()).toEqual([]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("handles empty config gracefully", async () => {
|
|
177
|
+
await manager.connectAll({ mcpServers: {} });
|
|
178
|
+
expect(manager.getConnectedServers()).toEqual([]);
|
|
179
|
+
expect(manager.getTools()).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("reconnects when connecting to an already-connected server name", async () => {
|
|
183
|
+
await manager.connectOne("srv", { type: "stdio", command: "node" });
|
|
184
|
+
expect(manager.getConnectedServers()).toEqual(["srv"]);
|
|
185
|
+
|
|
186
|
+
// Connect again with same name — should replace
|
|
187
|
+
await manager.connectOne("srv", { type: "remote", url: "https://example.com/mcp" });
|
|
188
|
+
expect(manager.getConnectedServers()).toEqual(["srv"]);
|
|
189
|
+
expect(manager.getTools()).toHaveLength(2);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("logs failures but continues connecting other servers", async () => {
|
|
193
|
+
const logs: string[] = [];
|
|
194
|
+
// Patch Client.connect to fail for a specific transport
|
|
195
|
+
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
|
|
196
|
+
const origConnect = Client.prototype.connect;
|
|
197
|
+
let callCount = 0;
|
|
198
|
+
Client.prototype.connect = async function (transport: unknown) {
|
|
199
|
+
callCount++;
|
|
200
|
+
if (callCount === 1) throw new Error("Connection refused");
|
|
201
|
+
return origConnect.call(
|
|
202
|
+
this,
|
|
203
|
+
transport as import("@modelcontextprotocol/sdk/shared/transport.js").Transport,
|
|
204
|
+
);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
await manager.connectAll(
|
|
209
|
+
{
|
|
210
|
+
mcpServers: {
|
|
211
|
+
failing: { type: "stdio", command: "bad-command" },
|
|
212
|
+
working: { type: "stdio", command: "good-command" },
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
(msg) => logs.push(msg),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
// The working server should still be connected
|
|
219
|
+
expect(manager.getConnectedServers()).toContain("working");
|
|
220
|
+
expect(logs.some((l) => l.includes("Failed to connect") && l.includes("failing"))).toBe(true);
|
|
221
|
+
} finally {
|
|
222
|
+
Client.prototype.connect = origConnect;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("updates tools when list_changed notification fires", async () => {
|
|
227
|
+
const logs: string[] = [];
|
|
228
|
+
await manager.connectAll({ mcpServers: { srv: { type: "stdio", command: "echo" } } }, (msg) =>
|
|
229
|
+
logs.push(msg),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(manager.getToolsForServer("srv")).toHaveLength(2);
|
|
233
|
+
|
|
234
|
+
// Simulate a tools/list_changed notification via the mock
|
|
235
|
+
const client = manager.getClient("srv") as unknown as {
|
|
236
|
+
_simulateToolsChanged: (tools: unknown[]) => void;
|
|
237
|
+
};
|
|
238
|
+
client._simulateToolsChanged([
|
|
239
|
+
{
|
|
240
|
+
name: "new_tool",
|
|
241
|
+
description: "A dynamically added tool",
|
|
242
|
+
inputSchema: { type: "object", properties: {} },
|
|
243
|
+
},
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
expect(manager.getToolsForServer("srv")).toHaveLength(1);
|
|
247
|
+
expect(manager.getToolsForServer("srv")[0].name).toBe("new_tool");
|
|
248
|
+
expect(logs.some((l) => l.includes("Tools updated"))).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
+
import type { McpConfig, ServerConfig } from "./config.js";
|
|
5
|
+
|
|
6
|
+
/** An MCP tool as discovered from a server. */
|
|
7
|
+
export interface McpTool {
|
|
8
|
+
/** Tool name as reported by the server. */
|
|
9
|
+
name: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
/** JSON Schema for the tool's parameters. */
|
|
12
|
+
inputSchema: Record<string, unknown>;
|
|
13
|
+
/** JSON Schema for structured output, if declared. */
|
|
14
|
+
outputSchema?: Record<string, unknown>;
|
|
15
|
+
/** Tool annotations (readOnlyHint, destructiveHint, etc.). */
|
|
16
|
+
annotations?: Record<string, unknown>;
|
|
17
|
+
/** Which configured server this tool came from. */
|
|
18
|
+
serverName: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ManagedConnection {
|
|
22
|
+
client: Client;
|
|
23
|
+
transport: StdioClientTransport | StreamableHTTPClientTransport;
|
|
24
|
+
tools: McpTool[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Manages connections to multiple MCP servers and aggregates their tools.
|
|
29
|
+
*
|
|
30
|
+
* Tools are discovered and stored internally. They are NOT registered with pi
|
|
31
|
+
* automatically — that responsibility belongs to the access tiers (Skills,
|
|
32
|
+
* Football, Code Mode).
|
|
33
|
+
*/
|
|
34
|
+
export class McpClientManager {
|
|
35
|
+
private connections = new Map<string, ManagedConnection>();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Connect to all servers defined in the config.
|
|
39
|
+
* Connections that fail are logged and skipped — partial success is fine.
|
|
40
|
+
*/
|
|
41
|
+
async connectAll(config: McpConfig, log: (msg: string) => void = console.error): Promise<void> {
|
|
42
|
+
const entries = Object.entries(config.mcpServers);
|
|
43
|
+
const results = await Promise.allSettled(
|
|
44
|
+
entries.map(([name, serverConfig]) => this.connectOne(name, serverConfig, log)),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < results.length; i++) {
|
|
48
|
+
const result = results[i];
|
|
49
|
+
if (result.status === "rejected") {
|
|
50
|
+
log(`[mcp] Failed to connect to "${entries[i][0]}": ${result.reason}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Connect to a single server by name. */
|
|
56
|
+
async connectOne(
|
|
57
|
+
name: string,
|
|
58
|
+
serverConfig: ServerConfig,
|
|
59
|
+
log: (msg: string) => void = console.error,
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
// Disconnect existing connection with this name if any
|
|
62
|
+
if (this.connections.has(name)) {
|
|
63
|
+
await this.disconnectOne(name);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const transport = createTransport(serverConfig);
|
|
67
|
+
|
|
68
|
+
const client = new Client(
|
|
69
|
+
{ name: "mcpi-ext", version: "0.1.0" },
|
|
70
|
+
{
|
|
71
|
+
capabilities: {},
|
|
72
|
+
listChanged: {
|
|
73
|
+
tools: {
|
|
74
|
+
onChanged: (_error, tools) => {
|
|
75
|
+
if (tools) {
|
|
76
|
+
const conn = this.connections.get(name);
|
|
77
|
+
if (conn && conn.client === client) {
|
|
78
|
+
conn.tools = toMcpTools(name, tools);
|
|
79
|
+
log(`[mcp] Tools updated for "${name}" (${conn.tools.length} tools)`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Insert placeholder so listChanged notifications during connect aren't dropped
|
|
89
|
+
this.connections.set(name, { client, transport, tools: [] });
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
await client.connect(transport);
|
|
93
|
+
|
|
94
|
+
const toolsResult = await client.listTools();
|
|
95
|
+
const tools = toMcpTools(name, toolsResult.tools);
|
|
96
|
+
const conn = this.connections.get(name);
|
|
97
|
+
if (conn && conn.client === client) {
|
|
98
|
+
conn.tools = tools;
|
|
99
|
+
}
|
|
100
|
+
log(`[mcp] Connected to "${name}" (${tools.length} tools)`);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
// Clean up on failure so we don't leak a partially connected client
|
|
103
|
+
const conn = this.connections.get(name);
|
|
104
|
+
if (conn && conn.client === client) {
|
|
105
|
+
this.connections.delete(name);
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await client.close();
|
|
109
|
+
} catch {
|
|
110
|
+
// best-effort cleanup
|
|
111
|
+
}
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Disconnect a single server. */
|
|
117
|
+
async disconnectOne(name: string): Promise<void> {
|
|
118
|
+
const conn = this.connections.get(name);
|
|
119
|
+
if (!conn) return;
|
|
120
|
+
this.connections.delete(name);
|
|
121
|
+
try {
|
|
122
|
+
await conn.client.close();
|
|
123
|
+
} catch {
|
|
124
|
+
// best-effort
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Disconnect all servers. */
|
|
129
|
+
async disconnectAll(): Promise<void> {
|
|
130
|
+
const names = [...this.connections.keys()];
|
|
131
|
+
await Promise.allSettled(names.map((n) => this.disconnectOne(n)));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Get all discovered tools across all connected servers. */
|
|
135
|
+
getTools(): McpTool[] {
|
|
136
|
+
const tools: McpTool[] = [];
|
|
137
|
+
for (const conn of this.connections.values()) {
|
|
138
|
+
tools.push(...conn.tools);
|
|
139
|
+
}
|
|
140
|
+
return tools;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Get tools from a specific server. */
|
|
144
|
+
getToolsForServer(name: string): McpTool[] {
|
|
145
|
+
return this.connections.get(name)?.tools ?? [];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Get the MCP Client for a specific server (needed to call tools). */
|
|
149
|
+
getClient(name: string): Client | undefined {
|
|
150
|
+
return this.connections.get(name)?.client;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** List connected server names. */
|
|
154
|
+
getConnectedServers(): string[] {
|
|
155
|
+
return [...this.connections.keys()];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function createTransport(
|
|
160
|
+
config: ServerConfig,
|
|
161
|
+
): StdioClientTransport | StreamableHTTPClientTransport {
|
|
162
|
+
if (config.type === "stdio") {
|
|
163
|
+
const env = config.env
|
|
164
|
+
? Object.fromEntries(
|
|
165
|
+
Object.entries({ ...process.env, ...config.env }).filter(
|
|
166
|
+
(entry): entry is [string, string] => entry[1] !== undefined,
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
: undefined;
|
|
170
|
+
return new StdioClientTransport({
|
|
171
|
+
command: config.command,
|
|
172
|
+
args: config.args,
|
|
173
|
+
env,
|
|
174
|
+
cwd: config.cwd,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const headers: Record<string, string> = {
|
|
179
|
+
...(config.headers ?? {}),
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return new StreamableHTTPClientTransport(new URL(config.url), {
|
|
183
|
+
requestInit: {
|
|
184
|
+
headers,
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
190
|
+
function toMcpTools(serverName: string, tools: any[]): McpTool[] {
|
|
191
|
+
return tools.map((t) => ({
|
|
192
|
+
name: t.name,
|
|
193
|
+
description: t.description,
|
|
194
|
+
inputSchema: t.inputSchema as Record<string, unknown>,
|
|
195
|
+
outputSchema: t.outputSchema as Record<string, unknown> | undefined,
|
|
196
|
+
annotations: t.annotations as Record<string, unknown> | undefined,
|
|
197
|
+
serverName,
|
|
198
|
+
}));
|
|
199
|
+
}
|