diff --git a/tools/server/webui/src/lib/mcp/client.ts b/tools/server/webui/src/lib/mcp/client.ts deleted file mode 100644 index 3f2b4ce411..0000000000 --- a/tools/server/webui/src/lib/mcp/client.ts +++ /dev/null @@ -1,388 +0,0 @@ -/** - * MCP Client implementation using the official @modelcontextprotocol/sdk - * - * This module provides a wrapper around the SDK's Client class that maintains - * backward compatibility with our existing MCPClient API. - */ - -import { Client } from '@modelcontextprotocol/sdk/client'; -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 type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; -import type { MCPClientConfig, MCPServerConfig, MCPToolCall } from '$lib/types/mcp'; -import { MCPError } from '$lib/types/mcp'; -import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp'; - -// Type for tool call result content item -interface ToolResultContentItem { - type: string; - text?: string; - data?: string; - mimeType?: string; - resource?: { text?: string; blob?: string; uri?: string }; -} - -// Type for tool call result (SDK uses complex union type) -interface ToolCallResult { - content?: ToolResultContentItem[]; - isError?: boolean; - _meta?: Record; -} - -interface ServerConnection { - client: Client; - transport: Transport; - tools: Tool[]; -} - -/** - * MCP Client using the official @modelcontextprotocol/sdk. - */ -export class MCPClient { - private readonly servers: Map = new Map(); - private readonly toolsToServer: Map = new Map(); - private readonly config: MCPClientConfig; - - constructor(config: MCPClientConfig) { - if (!config?.servers || Object.keys(config.servers).length === 0) { - throw new Error('MCPClient requires at least one server configuration'); - } - this.config = config; - } - - async initialize(): Promise { - const entries = Object.entries(this.config.servers); - const results = await Promise.allSettled( - entries.map(([name, serverConfig]) => this.initializeServer(name, serverConfig)) - ); - - // Log any failures but don't throw if at least one server connected - const failures = results.filter((r) => r.status === 'rejected'); - if (failures.length > 0) { - for (const failure of failures) { - console.error( - '[MCP] Server initialization failed:', - (failure as PromiseRejectedResult).reason - ); - } - } - - const successes = results.filter((r) => r.status === 'fulfilled'); - if (successes.length === 0) { - throw new Error('All MCP server connections failed'); - } - } - - listTools(): string[] { - return Array.from(this.toolsToServer.keys()); - } - - /** - * Normalize JSON Schema properties to ensure all have explicit types. - * Infers type from default value if missing - fixes compatibility with - * llama.cpp which requires explicit types in tool schemas. - */ - private normalizeSchemaProperties(schema: Record): Record { - if (!schema || typeof schema !== 'object') return schema; - - const normalized = { ...schema }; - - // Process properties object - if (normalized.properties && typeof normalized.properties === 'object') { - const props = normalized.properties as Record>; - const normalizedProps: Record> = {}; - - for (const [key, prop] of Object.entries(props)) { - if (!prop || typeof prop !== 'object') { - normalizedProps[key] = prop; - continue; - } - - const normalizedProp = { ...prop }; - - // Infer type from default if missing - if (!normalizedProp.type && normalizedProp.default !== undefined) { - const defaultVal = normalizedProp.default; - if (typeof defaultVal === 'string') { - normalizedProp.type = 'string'; - } else if (typeof defaultVal === 'number') { - normalizedProp.type = Number.isInteger(defaultVal) ? 'integer' : 'number'; - } else if (typeof defaultVal === 'boolean') { - normalizedProp.type = 'boolean'; - } else if (Array.isArray(defaultVal)) { - normalizedProp.type = 'array'; - } else if (typeof defaultVal === 'object' && defaultVal !== null) { - normalizedProp.type = 'object'; - } - } - - // Recursively normalize nested schemas - if (normalizedProp.properties) { - Object.assign( - normalizedProp, - this.normalizeSchemaProperties(normalizedProp as Record) - ); - } - - // Normalize items in array schemas - if (normalizedProp.items && typeof normalizedProp.items === 'object') { - normalizedProp.items = this.normalizeSchemaProperties( - normalizedProp.items as Record - ); - } - - normalizedProps[key] = normalizedProp; - } - - normalized.properties = normalizedProps; - } - - return normalized; - } - - async getToolsDefinition(): Promise< - { - type: 'function'; - function: { name: string; description?: string; parameters: Record }; - }[] - > { - const tools: { - type: 'function'; - function: { name: string; description?: string; parameters: Record }; - }[] = []; - - for (const [, server] of this.servers) { - for (const tool of server.tools) { - const rawSchema = (tool.inputSchema as Record) ?? { - type: 'object', - properties: {}, - required: [] - }; - - // Normalize schema to fix missing types - const normalizedSchema = this.normalizeSchemaProperties(rawSchema); - - tools.push({ - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: normalizedSchema - } - }); - } - } - - return tools; - } - - async execute(toolCall: MCPToolCall, abortSignal?: AbortSignal): Promise { - const toolName = toolCall.function.name; - const serverName = this.toolsToServer.get(toolName); - if (!serverName) { - throw new MCPError(`Unknown tool: ${toolName}`, -32601); - } - - const connection = this.servers.get(serverName); - if (!connection) { - throw new MCPError(`Server ${serverName} is not connected`, -32000); - } - - if (abortSignal?.aborted) { - throw new DOMException('Aborted', 'AbortError'); - } - - // Parse arguments - let args: Record; - const originalArgs = toolCall.function.arguments; - if (typeof originalArgs === 'string') { - const trimmed = originalArgs.trim(); - if (trimmed === '') { - args = {}; - } else { - try { - const parsed = JSON.parse(trimmed); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new MCPError( - `Tool arguments must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`, - -32602 - ); - } - args = parsed as Record; - } catch (error) { - if (error instanceof MCPError) { - throw error; - } - throw new MCPError( - `Failed to parse tool arguments as JSON: ${(error as Error).message}`, - -32700 - ); - } - } - } else if ( - typeof originalArgs === 'object' && - originalArgs !== null && - !Array.isArray(originalArgs) - ) { - args = originalArgs as Record; - } else { - throw new MCPError(`Invalid tool arguments type: ${typeof originalArgs}`, -32602); - } - - try { - const result = await connection.client.callTool( - { name: toolName, arguments: args }, - undefined, - { signal: abortSignal } - ); - - return MCPClient.formatToolResult(result as ToolCallResult); - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') { - throw error; - } - const message = error instanceof Error ? error.message : String(error); - throw new MCPError(`Tool execution failed: ${message}`, -32603); - } - } - - async shutdown(): Promise { - const closePromises: Promise[] = []; - - for (const [name, connection] of this.servers) { - console.log(`[MCP][${name}] Closing connection...`); - closePromises.push( - connection.client.close().catch((error) => { - console.warn(`[MCP][${name}] Error closing client:`, error); - }) - ); - } - - await Promise.allSettled(closePromises); - this.servers.clear(); - this.toolsToServer.clear(); - } - - private async initializeServer(name: string, config: MCPServerConfig): Promise { - console.log(`[MCP][${name}] Starting server initialization...`); - - const clientInfo = this.config.clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo; - const capabilities = - config.capabilities ?? this.config.capabilities ?? DEFAULT_MCP_CONFIG.capabilities; - - // Create SDK client - const client = new Client( - { name: clientInfo.name, version: clientInfo.version ?? '1.0.0' }, - { capabilities } - ); - - // Create transport with fallback - const transport = await this.createTransportWithFallback(name, config); - - console.log(`[MCP][${name}] Connecting to server...`); - await client.connect(transport); - console.log(`[MCP][${name}] Connected, listing tools...`); - - // List available tools - const toolsResult = await client.listTools(); - const tools = toolsResult.tools ?? []; - console.log(`[MCP][${name}] Found ${tools.length} tools`); - - // Store connection - const connection: ServerConnection = { - client, - transport, - tools - }; - this.servers.set(name, connection); - - // Map tools to server - for (const tool of tools) { - this.toolsToServer.set(tool.name, name); - } - - // Note: Tool list changes will be handled by re-calling listTools when needed - // The SDK's listChanged handler requires server capability support - - console.log(`[MCP][${name}] Server initialization complete`); - } - - private async createTransportWithFallback( - name: string, - config: MCPServerConfig - ): Promise { - if (!config.url) { - throw new Error('MCP server configuration is missing url'); - } - - const url = new URL(config.url); - const requestInit: RequestInit = {}; - - if (config.headers) { - requestInit.headers = config.headers; - } - if (config.credentials) { - requestInit.credentials = config.credentials; - } - - // Try StreamableHTTP first (modern), fall back to SSE (legacy) - try { - console.log(`[MCP][${name}] Trying StreamableHTTP transport...`); - const transport = new StreamableHTTPClientTransport(url, { - requestInit, - sessionId: config.sessionId - }); - return transport; - } catch (httpError) { - console.warn(`[MCP][${name}] StreamableHTTP failed, trying SSE transport...`, httpError); - - try { - const transport = new SSEClientTransport(url, { - requestInit - }); - return transport; - } catch (sseError) { - // Both failed, throw combined error - const httpMsg = httpError instanceof Error ? httpError.message : String(httpError); - const sseMsg = sseError instanceof Error ? sseError.message : String(sseError); - throw new Error(`Failed to create transport. StreamableHTTP: ${httpMsg}; SSE: ${sseMsg}`); - } - } - } - - private static formatToolResult(result: ToolCallResult): string { - const content = result.content; - if (Array.isArray(content)) { - return content - .map((item) => MCPClient.formatSingleContent(item)) - .filter(Boolean) - .join('\n'); - } - return ''; - } - - private static formatSingleContent(content: ToolResultContentItem): string { - if (content.type === 'text' && content.text) { - return content.text; - } - if (content.type === 'image' && content.data) { - return `data:${content.mimeType ?? 'image/png'};base64,${content.data}`; - } - if (content.type === 'resource' && content.resource) { - const resource = content.resource; - if (resource.text) { - return resource.text; - } - if (resource.blob) { - return resource.blob; - } - return JSON.stringify(resource); - } - // audio type - if (content.data && content.mimeType) { - return `data:${content.mimeType};base64,${content.data}`; - } - return JSON.stringify(content); - } -} diff --git a/tools/server/webui/src/lib/mcp/index.ts b/tools/server/webui/src/lib/mcp/index.ts index a1a14f2948..c3866313b9 100644 --- a/tools/server/webui/src/lib/mcp/index.ts +++ b/tools/server/webui/src/lib/mcp/index.ts @@ -8,9 +8,6 @@ export type { ToolExecutionResult } from './server-connection'; -// Legacy client export (deprecated - use MCPHostManager instead) -export { MCPClient } from './client'; - // Types export { MCPError } from '$lib/types/mcp'; export type { diff --git a/tools/server/webui/src/lib/mcp/server-connection.ts b/tools/server/webui/src/lib/mcp/server-connection.ts index 5c2a7fb503..52337ba5f3 100644 --- a/tools/server/webui/src/lib/mcp/server-connection.ts +++ b/tools/server/webui/src/lib/mcp/server-connection.ts @@ -10,6 +10,7 @@ import { Client } from '@modelcontextprotocol/sdk/client'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'; import type { Tool } from '@modelcontextprotocol/sdk/types.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { MCPServerConfig, ClientCapabilities, Implementation } from '$lib/types/mcp'; @@ -154,6 +155,11 @@ export class MCPServerConnection { requestInit.credentials = serverConfig.credentials; } + if (serverConfig.transport === 'websocket') { + console.log(`[MCP][${this.serverName}] Using WebSocket transport...`); + return new WebSocketClientTransport(url); + } + // Try StreamableHTTP first (modern), fall back to SSE (legacy) try { console.log(`[MCP][${this.serverName}] Trying StreamableHTTP transport...`); diff --git a/tools/server/webui/src/lib/stores/mcp.svelte.ts b/tools/server/webui/src/lib/stores/mcp.svelte.ts index 17a0221af4..b5a18463fd 100644 --- a/tools/server/webui/src/lib/stores/mcp.svelte.ts +++ b/tools/server/webui/src/lib/stores/mcp.svelte.ts @@ -4,13 +4,13 @@ import { type OpenAIToolDefinition, type ServerStatus } from '$lib/mcp/host-manager'; +import { MCPServerConnection } from '$lib/mcp/server-connection'; import type { ToolExecutionResult } from '$lib/mcp/server-connection'; import { buildMcpClientConfig, incrementMcpServerUsage } from '$lib/config/mcp'; import { config, settingsStore } from '$lib/stores/settings.svelte'; import type { MCPToolCall } from '$lib/types/mcp'; import type { McpServerOverride } from '$lib/types/database'; import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp'; -import { MCPClient } from '$lib/mcp'; import { detectMcpTransportFromUrl } from '$lib/utils/mcp'; // ───────────────────────────────────────────────────────────────────────────── @@ -391,27 +391,24 @@ class MCPStore { const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000); const headers = this.parseHeaders(server.headers); - const mcpClient = new MCPClient({ - protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion, - capabilities: DEFAULT_MCP_CONFIG.capabilities, + const connection = new MCPServerConnection({ + name: server.id, + server: { + url: trimmedUrl, + transport: detectMcpTransportFromUrl(trimmedUrl), + handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs, + requestTimeoutMs: timeoutMs, + headers + }, clientInfo: DEFAULT_MCP_CONFIG.clientInfo, - requestTimeoutMs: timeoutMs, - servers: { - [server.id]: { - url: trimmedUrl, - transport: detectMcpTransportFromUrl(trimmedUrl), - handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs, - requestTimeoutMs: timeoutMs, - headers - } - } + capabilities: DEFAULT_MCP_CONFIG.capabilities }); try { - await mcpClient.initialize(); - const tools = (await mcpClient.getToolsDefinition()).map((tool) => ({ - name: tool.function.name, - description: tool.function.description + await connection.connect(); + const tools = connection.tools.map((tool) => ({ + name: tool.name, + description: tool.description })); this.setHealthCheckState(server.id, { status: 'success', tools }); @@ -420,9 +417,12 @@ class MCPStore { this.setHealthCheckState(server.id, { status: 'error', message }); } finally { try { - await mcpClient.shutdown(); + await connection.disconnect(); } catch (shutdownError) { - console.warn('[MCP Store] Failed to cleanly shutdown health check client', shutdownError); + console.warn( + '[MCP Store] Failed to cleanly shutdown health check connection', + shutdownError + ); } } }