feat(skill-mcp): add MCP client manager with lazy loading and session cleanup
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
2
src/features/skill-mcp-manager/index.ts
Normal file
2
src/features/skill-mcp-manager/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./types"
|
||||||
|
export { SkillMcpManager } from "./manager"
|
||||||
109
src/features/skill-mcp-manager/manager.test.ts
Normal file
109
src/features/skill-mcp-manager/manager.test.ts
Normal file
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
210
src/features/skill-mcp-manager/manager.ts
Normal file
210
src/features/skill-mcp-manager/manager.ts
Normal file
@@ -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<string, ManagedClient> = new Map()
|
||||||
|
|
||||||
|
private getClientKey(info: SkillMcpClientInfo): string {
|
||||||
|
return `${info.sessionID}:${info.skillName}:${info.serverName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrCreateClient(
|
||||||
|
info: SkillMcpClientInfo,
|
||||||
|
config: ClaudeCodeMcpServer
|
||||||
|
): Promise<Client> {
|
||||||
|
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<Client> {
|
||||||
|
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<string, string> = {}
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<Tool[]> {
|
||||||
|
const client = await this.getOrCreateClientWithRetry(info, context.config)
|
||||||
|
const result = await client.listTools()
|
||||||
|
return result.tools
|
||||||
|
}
|
||||||
|
|
||||||
|
async listResources(
|
||||||
|
info: SkillMcpClientInfo,
|
||||||
|
context: SkillMcpServerContext
|
||||||
|
): Promise<Resource[]> {
|
||||||
|
const client = await this.getOrCreateClientWithRetry(info, context.config)
|
||||||
|
const result = await client.listResources()
|
||||||
|
return result.resources
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPrompts(
|
||||||
|
info: SkillMcpClientInfo,
|
||||||
|
context: SkillMcpServerContext
|
||||||
|
): Promise<Prompt[]> {
|
||||||
|
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<string, unknown>
|
||||||
|
): Promise<unknown> {
|
||||||
|
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<unknown> {
|
||||||
|
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<string, string>
|
||||||
|
): Promise<unknown> {
|
||||||
|
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<Client> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/features/skill-mcp-manager/types.ts
Normal file
14
src/features/skill-mcp-manager/types.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||||
|
|
||||||
|
export type SkillMcpConfig = Record<string, ClaudeCodeMcpServer>
|
||||||
|
|
||||||
|
export interface SkillMcpClientInfo {
|
||||||
|
serverName: string
|
||||||
|
skillName: string
|
||||||
|
sessionID: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillMcpServerContext {
|
||||||
|
config: ClaudeCodeMcpServer
|
||||||
|
skillName: string
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user