@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.
Files changed (213) hide show
  1. package/.dockerignore +6 -0
  2. package/.github/dependabot.yml +25 -0
  3. package/.github/workflows/ci.yml +50 -0
  4. package/.hadolint.yaml +6 -0
  5. package/.prettierignore +4 -0
  6. package/.prettierrc +7 -0
  7. package/AGENTS.md +118 -0
  8. package/DECISIONS.md +99 -0
  9. package/Dockerfile +39 -0
  10. package/README.md +278 -0
  11. package/aube-lock.yaml +4146 -0
  12. package/dist/code-mode/code-mode-integration.test.d.ts +2 -0
  13. package/dist/code-mode/code-mode-integration.test.d.ts.map +1 -0
  14. package/dist/code-mode/code-mode-integration.test.js +116 -0
  15. package/dist/code-mode/code-mode-integration.test.js.map +1 -0
  16. package/dist/code-mode/eligibility.d.ts +10 -0
  17. package/dist/code-mode/eligibility.d.ts.map +1 -0
  18. package/dist/code-mode/eligibility.js +14 -0
  19. package/dist/code-mode/eligibility.js.map +1 -0
  20. package/dist/code-mode/eligibility.test.d.ts +2 -0
  21. package/dist/code-mode/eligibility.test.d.ts.map +1 -0
  22. package/dist/code-mode/eligibility.test.js +53 -0
  23. package/dist/code-mode/eligibility.test.js.map +1 -0
  24. package/dist/code-mode/executor.d.ts +35 -0
  25. package/dist/code-mode/executor.d.ts.map +1 -0
  26. package/dist/code-mode/executor.js +120 -0
  27. package/dist/code-mode/executor.js.map +1 -0
  28. package/dist/code-mode/executor.test.d.ts +2 -0
  29. package/dist/code-mode/executor.test.d.ts.map +1 -0
  30. package/dist/code-mode/executor.test.js +130 -0
  31. package/dist/code-mode/executor.test.js.map +1 -0
  32. package/dist/code-mode/index.d.ts +65 -0
  33. package/dist/code-mode/index.d.ts.map +1 -0
  34. package/dist/code-mode/index.js +175 -0
  35. package/dist/code-mode/index.js.map +1 -0
  36. package/dist/code-mode/tools.d.ts +45 -0
  37. package/dist/code-mode/tools.d.ts.map +1 -0
  38. package/dist/code-mode/tools.js +70 -0
  39. package/dist/code-mode/tools.js.map +1 -0
  40. package/dist/code-mode/type-hints.d.ts +20 -0
  41. package/dist/code-mode/type-hints.d.ts.map +1 -0
  42. package/dist/code-mode/type-hints.js +189 -0
  43. package/dist/code-mode/type-hints.js.map +1 -0
  44. package/dist/code-mode/type-hints.test.d.ts +2 -0
  45. package/dist/code-mode/type-hints.test.d.ts.map +1 -0
  46. package/dist/code-mode/type-hints.test.js +157 -0
  47. package/dist/code-mode/type-hints.test.js.map +1 -0
  48. package/dist/docker-e2e.d.ts +55 -0
  49. package/dist/docker-e2e.d.ts.map +1 -0
  50. package/dist/docker-e2e.js +235 -0
  51. package/dist/docker-e2e.js.map +1 -0
  52. package/dist/docker-e2e.test.d.ts +2 -0
  53. package/dist/docker-e2e.test.d.ts.map +1 -0
  54. package/dist/docker-e2e.test.js +176 -0
  55. package/dist/docker-e2e.test.js.map +1 -0
  56. package/dist/index.d.ts +3 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +128 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/index.test.d.ts +2 -0
  61. package/dist/index.test.d.ts.map +1 -0
  62. package/dist/index.test.js +8 -0
  63. package/dist/index.test.js.map +1 -0
  64. package/dist/mcp/client-manager.d.ts +46 -0
  65. package/dist/mcp/client-manager.d.ts.map +1 -0
  66. package/dist/mcp/client-manager.js +148 -0
  67. package/dist/mcp/client-manager.js.map +1 -0
  68. package/dist/mcp/client-manager.test.d.ts +2 -0
  69. package/dist/mcp/client-manager.test.d.ts.map +1 -0
  70. package/dist/mcp/client-manager.test.js +204 -0
  71. package/dist/mcp/client-manager.test.js.map +1 -0
  72. package/dist/mcp/config-loader.d.ts +8 -0
  73. package/dist/mcp/config-loader.d.ts.map +1 -0
  74. package/dist/mcp/config-loader.js +43 -0
  75. package/dist/mcp/config-loader.js.map +1 -0
  76. package/dist/mcp/config-loader.test.d.ts +2 -0
  77. package/dist/mcp/config-loader.test.d.ts.map +1 -0
  78. package/dist/mcp/config-loader.test.js +93 -0
  79. package/dist/mcp/config-loader.test.js.map +1 -0
  80. package/dist/mcp/config.d.ts +46 -0
  81. package/dist/mcp/config.d.ts.map +1 -0
  82. package/dist/mcp/config.js +28 -0
  83. package/dist/mcp/config.js.map +1 -0
  84. package/dist/mcp/index.d.ts +5 -0
  85. package/dist/mcp/index.d.ts.map +1 -0
  86. package/dist/mcp/index.js +4 -0
  87. package/dist/mcp/index.js.map +1 -0
  88. package/dist/skills/discover.d.ts +10 -0
  89. package/dist/skills/discover.d.ts.map +1 -0
  90. package/dist/skills/discover.js +60 -0
  91. package/dist/skills/discover.js.map +1 -0
  92. package/dist/skills/format.d.ts +10 -0
  93. package/dist/skills/format.d.ts.map +1 -0
  94. package/dist/skills/format.js +38 -0
  95. package/dist/skills/format.js.map +1 -0
  96. package/dist/skills/format.test.d.ts +2 -0
  97. package/dist/skills/format.test.d.ts.map +1 -0
  98. package/dist/skills/format.test.js +63 -0
  99. package/dist/skills/format.test.js.map +1 -0
  100. package/dist/skills/index.d.ts +6 -0
  101. package/dist/skills/index.d.ts.map +1 -0
  102. package/dist/skills/index.js +6 -0
  103. package/dist/skills/index.js.map +1 -0
  104. package/dist/skills/load-skill-tool.d.ts +40 -0
  105. package/dist/skills/load-skill-tool.d.ts.map +1 -0
  106. package/dist/skills/load-skill-tool.js +104 -0
  107. package/dist/skills/load-skill-tool.js.map +1 -0
  108. package/dist/skills/mcp-tool-proxy.d.ts +11 -0
  109. package/dist/skills/mcp-tool-proxy.d.ts.map +1 -0
  110. package/dist/skills/mcp-tool-proxy.js +116 -0
  111. package/dist/skills/mcp-tool-proxy.js.map +1 -0
  112. package/dist/skills/skill-integration.test.d.ts +2 -0
  113. package/dist/skills/skill-integration.test.d.ts.map +1 -0
  114. package/dist/skills/skill-integration.test.js +119 -0
  115. package/dist/skills/skill-integration.test.js.map +1 -0
  116. package/dist/skills/skill-registry.d.ts +39 -0
  117. package/dist/skills/skill-registry.d.ts.map +1 -0
  118. package/dist/skills/skill-registry.js +48 -0
  119. package/dist/skills/skill-registry.js.map +1 -0
  120. package/dist/skills/skill-registry.test.d.ts +2 -0
  121. package/dist/skills/skill-registry.test.d.ts.map +1 -0
  122. package/dist/skills/skill-registry.test.js +67 -0
  123. package/dist/skills/skill-registry.test.js.map +1 -0
  124. package/dist/test-servers/weather-server.d.ts +12 -0
  125. package/dist/test-servers/weather-server.d.ts.map +1 -0
  126. package/dist/test-servers/weather-server.js +139 -0
  127. package/dist/test-servers/weather-server.js.map +1 -0
  128. package/dist/test-servers/weather-stdio.d.ts +2 -0
  129. package/dist/test-servers/weather-stdio.d.ts.map +1 -0
  130. package/dist/test-servers/weather-stdio.js +11 -0
  131. package/dist/test-servers/weather-stdio.js.map +1 -0
  132. package/dist/tool-cli/cli.d.ts +3 -0
  133. package/dist/tool-cli/cli.d.ts.map +1 -0
  134. package/dist/tool-cli/cli.js +179 -0
  135. package/dist/tool-cli/cli.js.map +1 -0
  136. package/dist/tool-cli/constants.d.ts +7 -0
  137. package/dist/tool-cli/constants.d.ts.map +1 -0
  138. package/dist/tool-cli/constants.js +15 -0
  139. package/dist/tool-cli/constants.js.map +1 -0
  140. package/dist/tool-cli/format.d.ts +8 -0
  141. package/dist/tool-cli/format.d.ts.map +1 -0
  142. package/dist/tool-cli/format.js +57 -0
  143. package/dist/tool-cli/format.js.map +1 -0
  144. package/dist/tool-cli/format.test.d.ts +2 -0
  145. package/dist/tool-cli/format.test.d.ts.map +1 -0
  146. package/dist/tool-cli/format.test.js +30 -0
  147. package/dist/tool-cli/format.test.js.map +1 -0
  148. package/dist/tool-cli/index.d.ts +4 -0
  149. package/dist/tool-cli/index.d.ts.map +1 -0
  150. package/dist/tool-cli/index.js +4 -0
  151. package/dist/tool-cli/index.js.map +1 -0
  152. package/dist/tool-cli/rpc-client.d.ts +6 -0
  153. package/dist/tool-cli/rpc-client.d.ts.map +1 -0
  154. package/dist/tool-cli/rpc-client.js +25 -0
  155. package/dist/tool-cli/rpc-client.js.map +1 -0
  156. package/dist/tool-cli/rpc-server.d.ts +48 -0
  157. package/dist/tool-cli/rpc-server.d.ts.map +1 -0
  158. package/dist/tool-cli/rpc-server.js +220 -0
  159. package/dist/tool-cli/rpc-server.js.map +1 -0
  160. package/dist/tool-cli/rpc-server.test.d.ts +2 -0
  161. package/dist/tool-cli/rpc-server.test.d.ts.map +1 -0
  162. package/dist/tool-cli/rpc-server.test.js +154 -0
  163. package/dist/tool-cli/rpc-server.test.js.map +1 -0
  164. package/eslint.config.js +27 -0
  165. package/images/banner.webp +0 -0
  166. package/images/code-c-maude.webp +0 -0
  167. package/images/nuclear-mcp-football.webp +0 -0
  168. package/images/the-skill-dealer.webp +0 -0
  169. package/mise.toml +65 -0
  170. package/package.json +46 -0
  171. package/scripts/docker-entrypoint.sh +29 -0
  172. package/scripts/test-echo-server.mjs +28 -0
  173. package/scripts/test-mcp-integration.mjs +59 -0
  174. package/scripts/test-tool-cli-smoke.mjs +62 -0
  175. package/src/code-mode/code-mode-integration.test.ts +135 -0
  176. package/src/code-mode/eligibility.test.ts +60 -0
  177. package/src/code-mode/eligibility.ts +16 -0
  178. package/src/code-mode/executor.test.ts +168 -0
  179. package/src/code-mode/executor.ts +163 -0
  180. package/src/code-mode/index.ts +208 -0
  181. package/src/code-mode/tools.ts +110 -0
  182. package/src/code-mode/type-hints.test.ts +187 -0
  183. package/src/code-mode/type-hints.ts +249 -0
  184. package/src/docker-e2e.test.ts +195 -0
  185. package/src/docker-e2e.ts +277 -0
  186. package/src/index.test.ts +8 -0
  187. package/src/index.ts +158 -0
  188. package/src/mcp/client-manager.test.ts +250 -0
  189. package/src/mcp/client-manager.ts +199 -0
  190. package/src/mcp/config-loader.test.ts +115 -0
  191. package/src/mcp/config-loader.ts +46 -0
  192. package/src/mcp/config.ts +40 -0
  193. package/src/mcp/index.ts +4 -0
  194. package/src/skills/discover.ts +81 -0
  195. package/src/skills/format.test.ts +70 -0
  196. package/src/skills/format.ts +42 -0
  197. package/src/skills/index.ts +9 -0
  198. package/src/skills/load-skill-tool.ts +136 -0
  199. package/src/skills/mcp-tool-proxy.ts +143 -0
  200. package/src/skills/skill-integration.test.ts +139 -0
  201. package/src/skills/skill-registry.test.ts +83 -0
  202. package/src/skills/skill-registry.ts +69 -0
  203. package/src/test-servers/weather-server.ts +171 -0
  204. package/src/test-servers/weather-stdio.ts +11 -0
  205. package/src/tool-cli/cli.ts +232 -0
  206. package/src/tool-cli/constants.ts +15 -0
  207. package/src/tool-cli/format.test.ts +33 -0
  208. package/src/tool-cli/format.ts +56 -0
  209. package/src/tool-cli/index.ts +3 -0
  210. package/src/tool-cli/rpc-client.ts +39 -0
  211. package/src/tool-cli/rpc-server.test.ts +193 -0
  212. package/src/tool-cli/rpc-server.ts +287 -0
  213. package/tsconfig.json +21 -0
@@ -0,0 +1,8 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ describe("mcpi-ext", () => {
4
+ it("exports a default function", async () => {
5
+ const mod = await import("./index.js");
6
+ expect(typeof mod.default).toBe("function");
7
+ });
8
+ });
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
+ }