|
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
|
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
|
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; |
|
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js"; |
|
|
import { MCP_SERVERS } from "../tools/mcp_servers"; |
|
|
import type { |
|
|
MCPServerConfig, |
|
|
MCPServerConnection, |
|
|
MCPClientState, |
|
|
MCPToolResult, |
|
|
} from "../types/mcp.js"; |
|
|
import { MCP_CLIENT_CONFIG, STORAGE_KEYS } from "../config/constants"; |
|
|
|
|
|
export class MCPClientService { |
|
|
private clients: Map<string, Client> = new Map(); |
|
|
private connections: Map<string, MCPServerConnection> = new Map(); |
|
|
private listeners: Array<(state: MCPClientState) => void> = []; |
|
|
|
|
|
constructor() { |
|
|
|
|
|
this.loadServerConfigs(); |
|
|
|
|
|
|
|
|
if (this.connections.size === 0) { |
|
|
MCP_SERVERS.forEach((config) => { |
|
|
this.addServer(config); |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
addStateListener(listener: (state: MCPClientState) => void) { |
|
|
this.listeners.push(listener); |
|
|
} |
|
|
|
|
|
|
|
|
removeStateListener(listener: (state: MCPClientState) => void) { |
|
|
const index = this.listeners.indexOf(listener); |
|
|
if (index > -1) { |
|
|
this.listeners.splice(index, 1); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
private notifyStateChange() { |
|
|
const state = this.getState(); |
|
|
this.listeners.forEach((listener) => listener(state)); |
|
|
} |
|
|
|
|
|
|
|
|
getState(): MCPClientState { |
|
|
const servers: Record<string, MCPServerConnection> = {}; |
|
|
for (const [id, connection] of this.connections) { |
|
|
servers[id] = connection; |
|
|
} |
|
|
|
|
|
return { |
|
|
servers, |
|
|
isLoading: false, |
|
|
error: undefined, |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
private loadServerConfigs() { |
|
|
try { |
|
|
const stored = localStorage.getItem(STORAGE_KEYS.MCP_SERVERS); |
|
|
if (stored) { |
|
|
const configs: MCPServerConfig[] = JSON.parse(stored); |
|
|
configs.forEach((config) => { |
|
|
const connection: MCPServerConnection = { |
|
|
config, |
|
|
isConnected: false, |
|
|
tools: [], |
|
|
lastError: undefined, |
|
|
lastConnected: undefined, |
|
|
}; |
|
|
this.connections.set(config.id, connection); |
|
|
}); |
|
|
} |
|
|
|
|
|
} catch (error) { |
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
private saveServerConfigs() { |
|
|
try { |
|
|
const configs = Array.from(this.connections.values()).map( |
|
|
(conn) => conn.config |
|
|
); |
|
|
localStorage.setItem(STORAGE_KEYS.MCP_SERVERS, JSON.stringify(configs)); |
|
|
} catch (error) { |
|
|
|
|
|
throw new Error( |
|
|
`Failed to save server configuration: ${ |
|
|
error instanceof Error ? error.message : "Unknown error" |
|
|
}` |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async addServer(config: MCPServerConfig): Promise<void> { |
|
|
const connection: MCPServerConnection = { |
|
|
config, |
|
|
isConnected: false, |
|
|
tools: [], |
|
|
lastError: undefined, |
|
|
lastConnected: undefined, |
|
|
}; |
|
|
|
|
|
this.connections.set(config.id, connection); |
|
|
this.saveServerConfigs(); |
|
|
this.notifyStateChange(); |
|
|
|
|
|
|
|
|
if (config.enabled) { |
|
|
await this.connectToServer(config.id); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async removeServer(serverId: string): Promise<void> { |
|
|
|
|
|
await this.disconnectFromServer(serverId); |
|
|
|
|
|
|
|
|
this.connections.delete(serverId); |
|
|
this.clients.delete(serverId); |
|
|
|
|
|
this.saveServerConfigs(); |
|
|
this.notifyStateChange(); |
|
|
} |
|
|
|
|
|
|
|
|
async connectToServer(serverId: string): Promise<void> { |
|
|
const connection = this.connections.get(serverId); |
|
|
if (!connection) { |
|
|
throw new Error(`Server ${serverId} not found`); |
|
|
} |
|
|
|
|
|
if (connection.isConnected) { |
|
|
return; |
|
|
} |
|
|
|
|
|
try { |
|
|
|
|
|
const client = new Client( |
|
|
{ |
|
|
name: MCP_CLIENT_CONFIG.NAME, |
|
|
version: MCP_CLIENT_CONFIG.VERSION, |
|
|
}, |
|
|
{ |
|
|
capabilities: { |
|
|
tools: {}, |
|
|
}, |
|
|
} |
|
|
); |
|
|
|
|
|
|
|
|
let transport; |
|
|
const url = new URL(connection.config.url); |
|
|
|
|
|
|
|
|
const headers: Record<string, string> = {}; |
|
|
if (connection.config.auth) { |
|
|
switch (connection.config.auth.type) { |
|
|
case "bearer": |
|
|
if (connection.config.auth.token) { |
|
|
headers[ |
|
|
"Authorization" |
|
|
] = `Bearer ${connection.config.auth.token}`; |
|
|
} |
|
|
break; |
|
|
case "basic": |
|
|
if ( |
|
|
connection.config.auth.username && |
|
|
connection.config.auth.password |
|
|
) { |
|
|
const credentials = btoa( |
|
|
`${connection.config.auth.username}:${connection.config.auth.password}` |
|
|
); |
|
|
headers["Authorization"] = `Basic ${credentials}`; |
|
|
} |
|
|
break; |
|
|
case "oauth": |
|
|
if (connection.config.auth.token) { |
|
|
headers[ |
|
|
"Authorization" |
|
|
] = `Bearer ${connection.config.auth.token}`; |
|
|
} |
|
|
break; |
|
|
} |
|
|
} |
|
|
|
|
|
switch (connection.config.transport) { |
|
|
case "streamable-http": |
|
|
transport = new StreamableHTTPClientTransport(url, { |
|
|
requestInit: |
|
|
Object.keys(headers).length > 0 ? { headers } : undefined, |
|
|
}); |
|
|
break; |
|
|
|
|
|
case "sse": |
|
|
transport = new SSEClientTransport(url, { |
|
|
requestInit: |
|
|
Object.keys(headers).length > 0 ? { headers } : undefined, |
|
|
}); |
|
|
break; |
|
|
|
|
|
default: |
|
|
throw new Error( |
|
|
`Unsupported transport: ${connection.config.transport}` |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
client.onerror = (error) => { |
|
|
connection.lastError = error.message; |
|
|
connection.isConnected = false; |
|
|
this.notifyStateChange(); |
|
|
}; |
|
|
|
|
|
|
|
|
await client.connect(transport); |
|
|
|
|
|
|
|
|
const toolsResult = await client.listTools(); |
|
|
|
|
|
|
|
|
connection.isConnected = true; |
|
|
connection.tools = toolsResult.tools; |
|
|
connection.lastError = undefined; |
|
|
connection.lastConnected = new Date(); |
|
|
|
|
|
|
|
|
this.clients.set(serverId, client); |
|
|
|
|
|
this.notifyStateChange(); |
|
|
} catch (error) { |
|
|
connection.isConnected = false; |
|
|
connection.lastError = |
|
|
error instanceof Error ? error.message : "Connection failed"; |
|
|
this.notifyStateChange(); |
|
|
throw error; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async disconnectFromServer(serverId: string): Promise<void> { |
|
|
const client = this.clients.get(serverId); |
|
|
const connection = this.connections.get(serverId); |
|
|
|
|
|
if (client) { |
|
|
try { |
|
|
await client.close(); |
|
|
|
|
|
} catch (error) { |
|
|
|
|
|
} |
|
|
this.clients.delete(serverId); |
|
|
} |
|
|
|
|
|
if (connection) { |
|
|
connection.isConnected = false; |
|
|
connection.tools = []; |
|
|
this.notifyStateChange(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
getAllTools(): Tool[] { |
|
|
const allTools: Tool[] = []; |
|
|
|
|
|
for (const connection of this.connections.values()) { |
|
|
if (connection.isConnected && connection.config.enabled) { |
|
|
allTools.push(...connection.tools); |
|
|
} |
|
|
} |
|
|
|
|
|
return allTools; |
|
|
} |
|
|
|
|
|
|
|
|
async callTool( |
|
|
serverId: string, |
|
|
toolName: string, |
|
|
args: Record<string, unknown> |
|
|
): Promise<MCPToolResult> { |
|
|
const client = this.clients.get(serverId); |
|
|
const connection = this.connections.get(serverId); |
|
|
|
|
|
if (!client || !connection?.isConnected) { |
|
|
throw new Error(`Not connected to server ${serverId}`); |
|
|
} |
|
|
|
|
|
try { |
|
|
const result = await client.callTool({ |
|
|
name: toolName, |
|
|
arguments: args, |
|
|
}); |
|
|
|
|
|
return { |
|
|
content: Array.isArray(result.content) ? result.content : [], |
|
|
isError: Boolean(result.isError), |
|
|
}; |
|
|
} catch (error) { |
|
|
throw new Error( |
|
|
`Tool execution failed (${toolName}): ${ |
|
|
error instanceof Error ? error.message : "Unknown error" |
|
|
}` |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async testConnection(config: MCPServerConfig): Promise<boolean> { |
|
|
try { |
|
|
const client = new Client( |
|
|
{ |
|
|
name: MCP_CLIENT_CONFIG.TEST_CLIENT_NAME, |
|
|
version: MCP_CLIENT_CONFIG.VERSION, |
|
|
}, |
|
|
{ |
|
|
capabilities: { |
|
|
tools: {}, |
|
|
}, |
|
|
} |
|
|
); |
|
|
|
|
|
let transport; |
|
|
const url = new URL(config.url); |
|
|
|
|
|
switch (config.transport) { |
|
|
case "streamable-http": |
|
|
transport = new StreamableHTTPClientTransport(url); |
|
|
break; |
|
|
|
|
|
case "sse": |
|
|
transport = new SSEClientTransport(url); |
|
|
break; |
|
|
|
|
|
default: |
|
|
throw new Error(`Unsupported transport: ${config.transport}`); |
|
|
} |
|
|
|
|
|
await client.connect(transport); |
|
|
await client.close(); |
|
|
return true; |
|
|
} catch (error) { |
|
|
throw new Error( |
|
|
`Connection test failed: ${ |
|
|
error instanceof Error ? error.message : "Unknown error" |
|
|
}` |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async connectAll(): Promise<void> { |
|
|
const promises = Array.from(this.connections.entries()) |
|
|
.filter( |
|
|
([, connection]) => connection.config.enabled && !connection.isConnected |
|
|
) |
|
|
.map(([serverId]) => |
|
|
this.connectToServer(serverId).catch(() => { |
|
|
|
|
|
}) |
|
|
); |
|
|
|
|
|
await Promise.all(promises); |
|
|
} |
|
|
|
|
|
|
|
|
async disconnectAll(): Promise<void> { |
|
|
const promises = Array.from(this.connections.keys()).map((serverId) => |
|
|
this.disconnectFromServer(serverId) |
|
|
); |
|
|
|
|
|
await Promise.all(promises); |
|
|
} |
|
|
} |
|
|
|