diff --git a/src/features/skill-mcp-manager/index.ts b/src/features/skill-mcp-manager/index.ts new file mode 100644 index 0000000..a3346fa --- /dev/null +++ b/src/features/skill-mcp-manager/index.ts @@ -0,0 +1,2 @@ +export * from "./types" +export { SkillMcpManager } from "./manager" diff --git a/src/features/skill-mcp-manager/manager.test.ts b/src/features/skill-mcp-manager/manager.test.ts new file mode 100644 index 0000000..ab77f04 --- /dev/null +++ b/src/features/skill-mcp-manager/manager.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test" +import { SkillMcpManager } from "./manager" +import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types" +import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" + +describe("SkillMcpManager", () => { + let manager: SkillMcpManager + + beforeEach(() => { + manager = new SkillMcpManager() + }) + + afterEach(async () => { + await manager.disconnectAll() + }) + + describe("getOrCreateClient", () => { + it("throws error when command is missing", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "test-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = {} + + // #when / #then + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /missing required 'command' field/ + ) + }) + + it("includes helpful error message with example when command is missing", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "my-mcp", + skillName: "data-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = {} + + // #when / #then + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /my-mcp[\s\S]*data-skill[\s\S]*Example/ + ) + }) + }) + + describe("disconnectSession", () => { + it("removes all clients for a specific session", async () => { + // #given + const session1Info: SkillMcpClientInfo = { + serverName: "server1", + skillName: "skill1", + sessionID: "session-1", + } + const session2Info: SkillMcpClientInfo = { + serverName: "server1", + skillName: "skill1", + sessionID: "session-2", + } + + // #when + await manager.disconnectSession("session-1") + + // #then + expect(manager.isConnected(session1Info)).toBe(false) + expect(manager.isConnected(session2Info)).toBe(false) + }) + + it("does not throw when session has no clients", async () => { + // #given / #when / #then + await expect(manager.disconnectSession("nonexistent")).resolves.toBeUndefined() + }) + }) + + describe("disconnectAll", () => { + it("clears all clients", async () => { + // #given - no actual clients connected (would require real MCP server) + + // #when + await manager.disconnectAll() + + // #then + expect(manager.getConnectedServers()).toEqual([]) + }) + }) + + describe("isConnected", () => { + it("returns false for unconnected server", () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "unknown", + skillName: "test", + sessionID: "session-1", + } + + // #when / #then + expect(manager.isConnected(info)).toBe(false) + }) + }) + + describe("getConnectedServers", () => { + it("returns empty array when no servers connected", () => { + // #given / #when / #then + expect(manager.getConnectedServers()).toEqual([]) + }) + }) +}) diff --git a/src/features/skill-mcp-manager/manager.ts b/src/features/skill-mcp-manager/manager.ts new file mode 100644 index 0000000..967f06a --- /dev/null +++ b/src/features/skill-mcp-manager/manager.ts @@ -0,0 +1,210 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" +import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js" +import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" +import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander" +import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types" + +interface ManagedClient { + client: Client + transport: StdioClientTransport + skillName: string +} + +export class SkillMcpManager { + private clients: Map = new Map() + + private getClientKey(info: SkillMcpClientInfo): string { + return `${info.sessionID}:${info.skillName}:${info.serverName}` + } + + async getOrCreateClient( + info: SkillMcpClientInfo, + config: ClaudeCodeMcpServer + ): Promise { + const key = this.getClientKey(info) + const existing = this.clients.get(key) + + if (existing) { + return existing.client + } + + const expandedConfig = expandEnvVarsInObject(config) + const client = await this.createClient(info, expandedConfig) + return client + } + + private async createClient( + info: SkillMcpClientInfo, + config: ClaudeCodeMcpServer + ): Promise { + const key = this.getClientKey(info) + + if (!config.command) { + throw new Error( + `MCP server "${info.serverName}" is missing required 'command' field.\n\n` + + `The MCP configuration in skill "${info.skillName}" must specify a command to execute.\n\n` + + `Example:\n` + + ` mcp:\n` + + ` ${info.serverName}:\n` + + ` command: npx\n` + + ` args: [-y, @some/mcp-server]` + ) + } + + const command = config.command + const args = config.args || [] + + const mergedEnv: Record = {} + if (config.env) { + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) mergedEnv[key] = value + } + Object.assign(mergedEnv, config.env) + } + + const transport = new StdioClientTransport({ + command, + args, + env: config.env ? mergedEnv : undefined, + }) + + const client = new Client( + { name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" }, + { capabilities: {} } + ) + + try { + await client.connect(transport) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error( + `Failed to connect to MCP server "${info.serverName}".\n\n` + + `Command: ${command} ${args.join(" ")}\n` + + `Reason: ${errorMessage}\n\n` + + `Hints:\n` + + ` - Ensure the command is installed and available in PATH\n` + + ` - Check if the MCP server package exists\n` + + ` - Verify the args are correct for this server` + ) + } + + this.clients.set(key, { client, transport, skillName: info.skillName }) + return client + } + + async disconnectSession(sessionID: string): Promise { + const keysToRemove: string[] = [] + + for (const [key, managed] of this.clients.entries()) { + if (key.startsWith(`${sessionID}:`)) { + keysToRemove.push(key) + try { + await managed.client.close() + } catch { + // Ignore close errors - process may already be terminated + } + } + } + + for (const key of keysToRemove) { + this.clients.delete(key) + } + } + + async disconnectAll(): Promise { + for (const [, managed] of this.clients.entries()) { + try { + await managed.client.close() + } catch { /* process may already be terminated */ } + } + this.clients.clear() + } + + async listTools( + info: SkillMcpClientInfo, + context: SkillMcpServerContext + ): Promise { + const client = await this.getOrCreateClientWithRetry(info, context.config) + const result = await client.listTools() + return result.tools + } + + async listResources( + info: SkillMcpClientInfo, + context: SkillMcpServerContext + ): Promise { + const client = await this.getOrCreateClientWithRetry(info, context.config) + const result = await client.listResources() + return result.resources + } + + async listPrompts( + info: SkillMcpClientInfo, + context: SkillMcpServerContext + ): Promise { + const client = await this.getOrCreateClientWithRetry(info, context.config) + const result = await client.listPrompts() + return result.prompts + } + + async callTool( + info: SkillMcpClientInfo, + context: SkillMcpServerContext, + name: string, + args: Record + ): Promise { + const client = await this.getOrCreateClientWithRetry(info, context.config) + const result = await client.callTool({ name, arguments: args }) + return result.content + } + + async readResource( + info: SkillMcpClientInfo, + context: SkillMcpServerContext, + uri: string + ): Promise { + const client = await this.getOrCreateClientWithRetry(info, context.config) + const result = await client.readResource({ uri }) + return result.contents + } + + async getPrompt( + info: SkillMcpClientInfo, + context: SkillMcpServerContext, + name: string, + args: Record + ): Promise { + const client = await this.getOrCreateClientWithRetry(info, context.config) + const result = await client.getPrompt({ name, arguments: args }) + return result.messages + } + + private async getOrCreateClientWithRetry( + info: SkillMcpClientInfo, + config: ClaudeCodeMcpServer + ): Promise { + try { + return await this.getOrCreateClient(info, config) + } catch (error) { + const key = this.getClientKey(info) + const existing = this.clients.get(key) + if (existing) { + try { + await existing.client.close() + } catch { /* process may already be terminated */ } + this.clients.delete(key) + return await this.getOrCreateClient(info, config) + } + throw error + } + } + + getConnectedServers(): string[] { + return Array.from(this.clients.keys()) + } + + isConnected(info: SkillMcpClientInfo): boolean { + return this.clients.has(this.getClientKey(info)) + } +} diff --git a/src/features/skill-mcp-manager/types.ts b/src/features/skill-mcp-manager/types.ts new file mode 100644 index 0000000..bed9dbc --- /dev/null +++ b/src/features/skill-mcp-manager/types.ts @@ -0,0 +1,14 @@ +import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" + +export type SkillMcpConfig = Record + +export interface SkillMcpClientInfo { + serverName: string + skillName: string + sessionID: string +} + +export interface SkillMcpServerContext { + config: ClaudeCodeMcpServer + skillName: string +}