import { Client } from '@modelcontextprotocol/sdk/client'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { StreamableHTTPClientTransport, StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { GetPromptResult, ListChangedHandlers, Prompt, Tool } from '@modelcontextprotocol/sdk/types.js'; import { CORS_PROXY_ENDPOINT, CORS_PROXY_HEADER_PREFIX, DEFAULT_CLIENT_VERSION, DEFAULT_IMAGE_MIME_TYPE, DEFAULT_MCP_CONFIG, MCP_PARTIAL_REDACT_HEADERS } from '$lib/constants'; import { MCPConnectionPhase, MCPContentType, MCPLogLevel, MCPRefType, MCPTransportType } from '$lib/enums'; import type { ClientCapabilities, Implementation, MCPConnection, MCPConnectionLog, MCPPhaseCallback, MCPReadResourceResult, MCPResource, MCPResourceContent, MCPResourceIcon, MCPResourceTemplate, MCPServerConfig, MCPServerInfo, ToolCallParams, ToolExecutionResult } from '$lib/types'; import { buildProxiedHeaders, buildProxiedUrl, createBase64DataUrl, extractJsonRpcMethods, formatDiagnosticErrorMessage, getAuthHeaders, getRequestBody, getRequestMethod, getRequestUrl, isAbortError, type RequestBodySummary, sanitizeHeaders, summarizeRequestBody, throwIfAborted } from '$lib/utils'; interface ToolResultContentItem { type: string; text?: string; data?: string; mimeType?: string; resource?: { text?: string; blob?: string; uri?: string }; } interface ToolCallResult { content?: ToolResultContentItem[]; isError?: boolean; _meta?: Record; } interface DiagnosticRequestDetails { url: string; method: string; credentials?: RequestCredentials; mode?: RequestMode; headers: Record; body: RequestBodySummary; jsonRpcMethods?: string[]; } export class MCPService { /** * Create a connection log entry for phase tracking. * * @param phase - The connection phase this log belongs to * @param message - Human-readable log message * @param level - Log severity level (default: INFO) * @param details - Optional structured details for debugging * @returns Formatted connection log entry */ private static createLog( phase: MCPConnectionPhase, message: string, level: MCPLogLevel = MCPLogLevel.INFO, details?: unknown ): MCPConnectionLog { return { details, level, message, phase, timestamp: new Date() }; } private static createDiagnosticRequestDetails( input: RequestInfo | URL, init: RequestInit | undefined, baseInit: RequestInit, requestHeaders: Headers, extraRedactedHeaders?: Iterable ): DiagnosticRequestDetails { const body = getRequestBody(input, init); const details: DiagnosticRequestDetails = { body: summarizeRequestBody(body), credentials: init?.credentials ?? baseInit.credentials, headers: sanitizeHeaders(requestHeaders, extraRedactedHeaders, MCP_PARTIAL_REDACT_HEADERS), method: getRequestMethod(input, init, baseInit).toUpperCase(), mode: init?.mode ?? baseInit.mode, url: getRequestUrl(input) }; const jsonRpcMethods = extractJsonRpcMethods(body); if (jsonRpcMethods) { details.jsonRpcMethods = jsonRpcMethods; } return details; } private static addRequestHeaders( requestHeaders: Headers, headers: HeadersInit, useProxy: boolean ) { for (const [key, value] of new Headers(headers).entries()) { const proxiedKey = useProxy && !key.toLowerCase().startsWith(CORS_PROXY_HEADER_PREFIX) ? `${CORS_PROXY_HEADER_PREFIX}${key}` : key; requestHeaders.set(proxiedKey, value); } } private static summarizeError(error: unknown): Record { if (error instanceof Error) { return { cause: error.cause instanceof Error ? { message: error.cause.message, name: error.cause.name } : error.cause, message: error.message, name: error.name, stack: error.stack?.split('\n').slice(0, 6).join('\n') }; } return { value: String(error) }; } private static getBrowserContext( targetUrl: URL, useProxy: boolean ): Record | undefined { if (typeof window === 'undefined') { return undefined; } return { isSecureContext: window.isSecureContext, location: window.location.href, origin: window.location.origin, protocol: window.location.protocol, sameOrigin: window.location.origin === targetUrl.origin, targetOrigin: targetUrl.origin, targetProtocol: targetUrl.protocol, useProxy }; } private static getConnectionHints( targetUrl: URL, config: MCPServerConfig, error: unknown ): string[] { const hints: string[] = []; const message = error instanceof Error ? error.message : String(error); const headerNames = Object.keys(config.headers ?? {}); if (typeof window !== 'undefined') { if ( window.location.protocol === 'https:' && targetUrl.protocol === 'http:' && !config.useProxy ) { hints.push( 'The page is running over HTTPS but the MCP server is HTTP. Browsers often block this as mixed content; enable the proxy or use HTTPS/WSS for the MCP server.' ); } if (window.location.origin !== targetUrl.origin && !config.useProxy) { hints.push( 'This is a cross-origin browser request. If the server is reachable from curl or Node but not from the browser, missing CORS headers are the most likely cause.' ); } } if (headerNames.length > 0) { hints.push( `Custom request headers are configured (${headerNames.join(', ')}). That triggers a CORS preflight, so the server must allow OPTIONS and include the matching Access-Control-Allow-Headers response.` ); } if (config.credentials && config.credentials !== 'omit') { hints.push( 'Credentials are enabled for this connection. Cross-origin credentialed requests need Access-Control-Allow-Credentials: true and cannot use a wildcard Access-Control-Allow-Origin.' ); } if (message.includes('Failed to fetch')) { hints.push( '"Failed to fetch" is a browser-level network failure. Common causes are CORS rejection, mixed-content blocking, certificate/TLS errors, DNS failures, or nothing listening on the target port.' ); } return hints; } private static createDiagnosticFetch( serverName: string, config: MCPServerConfig, baseInit: RequestInit, targetUrl: URL, useProxy: boolean, onLog?: (log: MCPConnectionLog) => void ): { fetch: typeof fetch; disable: () => void; } { let enabled = true; const logIfEnabled = (log: MCPConnectionLog) => { if (enabled) { onLog?.(log); } }; return { disable: () => { enabled = false; }, fetch: async (input, init) => { if (useProxy && typeof window !== 'undefined') { let requestUrlStr = ''; if (typeof input === 'string') { requestUrlStr = input; } else if (input instanceof URL) { requestUrlStr = input.href; } if (requestUrlStr) { const parsedRequestUrl = new URL(requestUrlStr, window.location.origin); if ( parsedRequestUrl.origin === window.location.origin && !parsedRequestUrl.pathname.includes(CORS_PROXY_ENDPOINT) ) { const originalConfigUrl = new URL(config.url); const realTargetUrl = new URL( parsedRequestUrl.pathname + parsedRequestUrl.search, originalConfigUrl.origin ); const proxiedUrl = buildProxiedUrl(realTargetUrl.href); if (typeof input === 'string') { input = proxiedUrl.href; } else if (input instanceof URL) { input = proxiedUrl; } } } } const startedAt = performance.now(); const requestHeaders = new Headers(baseInit.headers); if (typeof Request !== 'undefined' && input instanceof Request) { this.addRequestHeaders(requestHeaders, input.headers, useProxy); } if (init?.headers) { this.addRequestHeaders(requestHeaders, init.headers, useProxy); } const request = this.createDiagnosticRequestDetails( input, init, baseInit, requestHeaders, Object.keys(config.headers ?? {}) ); const { method, url } = request; logIfEnabled( this.createLog( MCPConnectionPhase.INITIALIZING, `HTTP ${method} ${url}`, MCPLogLevel.INFO, { request, serverName } ) ); if (method === 'DELETE' && url.includes(CORS_PROXY_ENDPOINT)) { const response = new Response(null, { status: 200, statusText: 'OK' }); logIfEnabled( this.createLog( MCPConnectionPhase.INITIALIZING, `HTTP 200 ${method} ${url} (fake response)`, MCPLogLevel.INFO, { response: { durationMs: 0, isFake: true, status: response.status, statusText: response.statusText, url } } ) ); // fake response, bypass real fetch() return response; } try { const response = await fetch(input, { ...baseInit, ...init, headers: requestHeaders }); const durationMs = Math.round(performance.now() - startedAt); logIfEnabled( this.createLog( MCPConnectionPhase.INITIALIZING, `HTTP ${response.status} ${method} ${url} (${durationMs}ms)`, response.ok ? MCPLogLevel.INFO : MCPLogLevel.WARN, { response: { durationMs, headers: sanitizeHeaders(response.headers, undefined, MCP_PARTIAL_REDACT_HEADERS), status: response.status, statusText: response.statusText, url } } ) ); return response; } catch (error) { const durationMs = Math.round(performance.now() - startedAt); logIfEnabled( this.createLog( MCPConnectionPhase.ERROR, `HTTP ${method} ${url} failed: ${formatDiagnosticErrorMessage(error)}`, MCPLogLevel.ERROR, { browser: this.getBrowserContext(targetUrl, useProxy), durationMs, error: this.summarizeError(error), hints: this.getConnectionHints(targetUrl, config, error), request, serverName } ) ); throw error; } } }; } /** * Detect if an error indicates an expired/invalidated MCP session. * Per MCP spec 2025-11-25: HTTP 404 means session invalidated, client MUST * discard its session ID and start a new session with a fresh initialize request. * * @param error - The caught error to inspect * @returns true if the error is a StreamableHTTP 404 (session not found) */ static isSessionExpiredError(error: unknown): boolean { return error instanceof StreamableHTTPError && error.code === 404; } /** * Create transport based on server configuration. * Supports WebSocket, StreamableHTTP (modern), and SSE (legacy) transports. * When `useProxy` is enabled, routes HTTP requests through llama-server's CORS proxy. * * **Fallback Order:** * 1. WebSocket — if explicitly configured (no CORS proxy support) * 2. StreamableHTTP — default for HTTP connections * 3. SSE — automatic fallback if StreamableHTTP fails * * @param config - Server configuration with url, transport type, proxy, and auth settings * @returns Object containing the created transport and the transport type used * @throws {Error} If url is missing, WebSocket + proxy combination, or all transports fail */ static createTransport( serverName: string, config: MCPServerConfig, onLog?: (log: MCPConnectionLog) => void ): { transport: Transport; type: MCPTransportType; stopPhaseLogging: () => void; } { if (!config.url) { throw new Error('MCP server configuration is missing url'); } const useProxy = config.useProxy ?? false; const requestInit: RequestInit = {}; if (config.headers) { requestInit.headers = config.useProxy ? buildProxiedHeaders(config.headers) : config.headers; } if (useProxy) { requestInit.headers = { ...getAuthHeaders(), ...(requestInit.headers as Record) }; } if (config.credentials) { requestInit.credentials = config.credentials; } if (config.transport === MCPTransportType.WEBSOCKET) { if (useProxy) { throw new Error( 'WebSocket transport is not supported when using CORS proxy. Use HTTP transport instead.' ); } const url = new URL(config.url); if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) { console.log(`[MCPService] Creating WebSocket transport for ${url.href}`); } return { stopPhaseLogging: () => {}, transport: new WebSocketClientTransport(url), type: MCPTransportType.WEBSOCKET }; } if (config.transport === MCPTransportType.SSE) { const url = useProxy ? buildProxiedUrl(config.url) : new URL(config.url); const { disable: stopPhaseLogging, fetch: diagnosticFetch } = this.createDiagnosticFetch( serverName, config, requestInit, url, useProxy, onLog ); if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) { console.log(`[MCPService] Creating SSE transport for ${url.href}`); } return { stopPhaseLogging, transport: new SSEClientTransport(url, { eventSourceInit: { fetch: diagnosticFetch }, fetch: diagnosticFetch, requestInit }), type: MCPTransportType.SSE }; } const url = useProxy ? buildProxiedUrl(config.url) : new URL(config.url); const { disable: stopPhaseLogging, fetch: diagnosticFetch } = this.createDiagnosticFetch( serverName, config, requestInit, url, useProxy, onLog ); if (useProxy && import.meta.env.DEV && import.meta.env.VITE_DEBUG) { console.log(`[MCPService] Using CORS proxy for ${config.url} -> ${url.href}`); } try { if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) { console.log(`[MCPService] Creating StreamableHTTP transport for ${url.href}`); } return { stopPhaseLogging, transport: new StreamableHTTPClientTransport(url, { fetch: diagnosticFetch, requestInit }), type: MCPTransportType.STREAMABLE_HTTP }; } catch (httpError) { console.warn(`[MCPService] StreamableHTTP failed, trying SSE transport...`, httpError); try { return { stopPhaseLogging, transport: new SSEClientTransport(url, { eventSourceInit: { fetch: diagnosticFetch }, fetch: diagnosticFetch, requestInit }), type: MCPTransportType.SSE }; } catch (sseError) { 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}`); } } } /** * Extract server info from SDK Implementation type. * Normalizes the SDK's server version response into our MCPServerInfo type. * * @param impl - Raw Implementation object from MCP SDK * @returns Normalized server info or undefined if input is empty */ private static extractServerInfo(impl: Implementation | undefined): MCPServerInfo | undefined { if (!impl) { return undefined; } return { description: impl.description, icons: impl.icons?.map((icon: MCPResourceIcon) => ({ mimeType: icon.mimeType, sizes: icon.sizes, src: icon.src, theme: icon.theme })), name: impl.name, title: impl.title, version: impl.version, websiteUrl: impl.websiteUrl }; } /** * Connect to a single MCP server with detailed phase tracking. * * Performs the full MCP connection lifecycle: * 1. Transport creation (with automatic fallback) * 2. Client initialization and capability exchange * 3. Tool discovery via `listTools` * * Reports progress via `onPhase` callback at each step, enabling * UI progress indicators during connection. * * @param serverName - Display name for the server (used in logging) * @param serverConfig - Server URL, transport type, proxy, and auth configuration * @param clientInfo - Optional client identification (defaults to app info) * @param capabilities - Optional client capability declaration * @param onPhase - Optional callback for connection phase progress updates * @param listChangedHandlers - Optional handlers for server-initiated list change notifications * @returns Full connection object with client, transport, tools, server info, and timing * @throws {Error} If transport creation or connection fails */ static async connect( serverName: string, serverConfig: MCPServerConfig, clientInfo?: Implementation, capabilities?: ClientCapabilities, onPhase?: MCPPhaseCallback, listChangedHandlers?: ListChangedHandlers ): Promise { const startTime = performance.now(); const effectiveClientInfo = clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo; const effectiveCapabilities = capabilities ?? DEFAULT_MCP_CONFIG.capabilities; // Phase: Creating transport onPhase?.( MCPConnectionPhase.TRANSPORT_CREATING, this.createLog( MCPConnectionPhase.TRANSPORT_CREATING, `Creating transport for ${serverConfig.url}` ) ); if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) { console.log(`[MCPService][${serverName}] Creating transport...`); } const { stopPhaseLogging, transport, type: transportType } = this.createTransport(serverName, serverConfig, (log) => onPhase?.(log.phase, log)); // Setup WebSocket reconnection handler if (transportType === MCPTransportType.WEBSOCKET) { transport.onclose = () => { console.log(`[MCPService][${serverName}] WebSocket closed, notifying for reconnection`); onPhase?.( MCPConnectionPhase.DISCONNECTED, this.createLog(MCPConnectionPhase.DISCONNECTED, 'WebSocket connection closed') ); }; } // Phase: Transport ready onPhase?.( MCPConnectionPhase.TRANSPORT_READY, this.createLog(MCPConnectionPhase.TRANSPORT_READY, `Transport ready (${transportType})`), { transportType } ); const client = new Client( { name: effectiveClientInfo.name, version: effectiveClientInfo.version ?? DEFAULT_CLIENT_VERSION }, { capabilities: effectiveCapabilities, listChanged: listChangedHandlers } ); const runtimeErrorHandler = (error: Error) => { // the SDK reports any post initialize error here, including the abort we trigger // ourselves on the next health check cycle, on tab unload, or on server teardown. // these are lifecycle aborts, not actionable errors, so we keep them out of the red console. // the SDK wraps the original AbortError in a generic Error like // "SSE stream disconnected: AbortError: The operation was aborted." // which isAbortError cannot recognize by name alone, so we also pattern match on the message if (isAbortError(error)) { return; } const msg = error?.message ?? ''; if ( /SSE stream disconnected:.*AbortError/i.test(msg) || /AbortError: .*aborted/i.test(msg) || /stream locked by a reader/i.test(msg) ) { return; } console.error(`[MCPService][${serverName}] Protocol error after initialize:`, error); }; client.onerror = (error) => { onPhase?.( MCPConnectionPhase.ERROR, this.createLog( MCPConnectionPhase.ERROR, `Protocol error: ${error.message}`, MCPLogLevel.ERROR, { error: this.summarizeError(error) } ) ); }; // Phase: Initializing onPhase?.( MCPConnectionPhase.INITIALIZING, this.createLog(MCPConnectionPhase.INITIALIZING, 'Sending initialize request...') ); // The SDK timeout only covers the initialize request, not transport.start(), // which can hang forever on an unreachable host (SSE endpoint wait, WebSocket // handshake, proxied fetch). This race bounds the whole handshake and closes // the transport on expiry so the underlying fetch or socket is aborted. const handshakeTimeoutMs = serverConfig.handshakeTimeoutMs ?? DEFAULT_MCP_CONFIG.connectionTimeoutMs; try { let handshakeTimer: ReturnType | undefined; const handshakeDeadline = new Promise((_, reject) => { handshakeTimer = setTimeout(() => { void transport.close().catch(() => {}); reject(new Error(`Connection timed out after ${Math.round(handshakeTimeoutMs / 1000)}s`)); }, handshakeTimeoutMs); }); try { await Promise.race([ client.connect(transport, { timeout: handshakeTimeoutMs }), handshakeDeadline ]); } finally { clearTimeout(handshakeTimer); } // Transport diagnostics are only for the initial handshake, not long-lived traffic. stopPhaseLogging(); client.onerror = runtimeErrorHandler; } catch (error) { client.onerror = runtimeErrorHandler; const url = (serverConfig.useProxy ?? false) ? buildProxiedUrl(serverConfig.url) : new URL(serverConfig.url); onPhase?.( MCPConnectionPhase.ERROR, this.createLog( MCPConnectionPhase.ERROR, `Connection failed during initialize: ${ error instanceof Error ? error.message : String(error) }`, MCPLogLevel.ERROR, { browser: this.getBrowserContext(url, serverConfig.useProxy ?? false), config: { configuredUrl: serverConfig.url, credentials: serverConfig.credentials, effectiveUrl: url.href, headers: sanitizeHeaders( serverConfig.headers, Object.keys(serverConfig.headers ?? {}), MCP_PARTIAL_REDACT_HEADERS ), serverName, transportType, useProxy: serverConfig.useProxy ?? false }, error: this.summarizeError(error), hints: this.getConnectionHints(url, serverConfig, error) } ) ); throw error; } const serverVersion = client.getServerVersion(); const serverCapabilities = client.getServerCapabilities(); const instructions = client.getInstructions(); const serverInfo = this.extractServerInfo(serverVersion); // Phase: Capabilities exchanged onPhase?.( MCPConnectionPhase.CAPABILITIES_EXCHANGED, this.createLog( MCPConnectionPhase.CAPABILITIES_EXCHANGED, 'Capabilities exchanged successfully', MCPLogLevel.INFO, { serverCapabilities, serverInfo } ), { clientCapabilities: effectiveCapabilities, instructions, serverCapabilities, serverInfo } ); // Phase: Listing tools onPhase?.( MCPConnectionPhase.LISTING_TOOLS, this.createLog(MCPConnectionPhase.LISTING_TOOLS, 'Listing available tools...') ); if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) { console.log(`[MCPService][${serverName}] Connected, listing tools...`); } const tools = await this.listTools({ client, connectionTimeMs: 0, requestTimeoutMs: serverConfig.requestTimeoutMs ?? DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000, serverName, tools: [], transport, transportType }); const connectionTimeMs = Math.round(performance.now() - startTime); // Phase: Connected onPhase?.( MCPConnectionPhase.CONNECTED, this.createLog( MCPConnectionPhase.CONNECTED, `Connection established with ${tools.length} tools (${connectionTimeMs}ms)` ) ); if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) { console.log( `[MCPService][${serverName}] Initialization complete with ${tools.length} tools in ${connectionTimeMs}ms` ); } return { client, clientCapabilities: effectiveCapabilities, connectionTimeMs, instructions, protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion, requestTimeoutMs: serverConfig.requestTimeoutMs ?? DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000, serverCapabilities, serverInfo, serverName, tools, transport, transportType }; } /** * Disconnect from a server. * Clears the `onclose` handler to prevent reconnection attempts on voluntary disconnect. * * @param connection - The active MCP connection to close */ static async disconnect(connection: MCPConnection): Promise { if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) { console.log(`[MCPService][${connection.serverName}] Disconnecting...`); } try { // Terminate the session first for streamable-http transports to cleanly // close streams, matching the inspector's disconnect flow. if (connection.transport instanceof StreamableHTTPClientTransport) { await connection.transport.terminateSession(); } // Clear error handlers before closing to prevent noise from expected // abort errors during shutdown. The inspector avoids this entirely // by not setting onerror, but since we use it for protocol logging, // we must clear it before disconnect. connection.client.onerror = undefined; if (connection.transport.onclose) { connection.transport.onclose = undefined; } await connection.client.close(); } catch (error) { console.warn(`[MCPService][${connection.serverName}] Error during disconnect:`, error); } } /** * List tools from a connection. * Silently returns empty array on failure (logged as warning). * * @param connection - The MCP connection to query * @returns Array of available tools, or empty array on error */ static async listTools(connection: MCPConnection): Promise { try { const result = await connection.client.listTools(); return result.tools ?? []; } catch (error) { // Let session-expired errors propagate for reconnection handling if (this.isSessionExpiredError(error)) { throw error; } console.warn(`[MCPService][${connection.serverName}] Failed to list tools:`, error); return []; } } /** * List prompts from a connection. * Silently returns empty array on failure (logged as warning). * * @param connection - The MCP connection to query * @returns Array of available prompts, or empty array on error */ static async listPrompts(connection: MCPConnection): Promise { try { const result = await connection.client.listPrompts(); return result.prompts ?? []; } catch (error) { // Let session-expired errors propagate for reconnection handling if (this.isSessionExpiredError(error)) { throw error; } console.warn(`[MCPService][${connection.serverName}] Failed to list prompts:`, error); return []; } } /** * Get a specific prompt with arguments. * Unlike list operations, this throws on failure since the caller explicitly * requested a specific prompt and needs to handle the error. * * @param connection - The MCP connection to use * @param name - The prompt name to retrieve * @param args - Optional key-value arguments to pass to the prompt * @returns The prompt result with messages and metadata * @throws {Error} If the prompt retrieval fails */ static async getPrompt( connection: MCPConnection, name: string, args?: Record ): Promise { try { return await connection.client.getPrompt({ arguments: args, name }); } catch (error) { console.error(`[MCPService][${connection.serverName}] Failed to get prompt:`, error); throw error; } } /** * Execute a tool call on a connection. * Supports abort signal for cancellable operations (e.g., when user stops generation). * Formats the raw tool result into a string representation. * * @param connection - The MCP connection to execute against * @param params - Tool name and arguments to execute * @param signal - Optional AbortSignal for cancellation support * @returns Formatted tool execution result with content string and error flag * @throws {Error} If tool execution fails or is aborted */ static async callTool( connection: MCPConnection, params: ToolCallParams, signal?: AbortSignal ): Promise { throwIfAborted(signal); try { const result = await connection.client.callTool( { arguments: params.arguments, name: params.name }, undefined, { signal, timeout: connection.requestTimeoutMs } ); return { content: this.formatToolResult(result as ToolCallResult), isError: (result as ToolCallResult).isError ?? false }; } catch (error) { if (isAbortError(error)) { throw error; } // Let session-expired errors propagate unwrapped for reconnection handling if (this.isSessionExpiredError(error)) { throw error; } const message = error instanceof Error ? error.message : String(error); throw new Error( `Tool "${params.name}" execution failed on server "${connection.serverName}": ${message}`, { cause: error instanceof Error ? error : undefined } ); } } /** * Format tool result content items to a single string. * Handles text, image (base64 data URL), and embedded resource content types. * * @param result - Raw tool call result from MCP SDK * @returns Concatenated string representation of all content items */ private static formatToolResult(result: ToolCallResult): string { const content = result.content; if (!Array.isArray(content)) return ''; return content .map((item) => this.formatSingleContent(item)) .filter(Boolean) .join('\n'); } private static formatSingleContent(content: ToolResultContentItem): string { if (content.type === MCPContentType.TEXT && content.text) { return content.text; } if (content.type === MCPContentType.IMAGE && content.data) { return createBase64DataUrl(content.mimeType ?? DEFAULT_IMAGE_MIME_TYPE, content.data); } if (content.type === MCPContentType.RESOURCE && content.resource) { const resource = content.resource; if (resource.text) return resource.text; if (resource.blob) return resource.blob; return JSON.stringify(resource); } if (content.data && content.mimeType) { return createBase64DataUrl(content.mimeType, content.data); } return JSON.stringify(content); } /** * * * Completions Operations * * */ /** * Request completion suggestions from a server. * Used for autocompleting prompt arguments or resource URI templates. * * @param connection - The MCP connection to use * @param ref - Reference to the prompt or resource template * @param argument - The argument being completed (name and current value) * @returns Completion result with suggested values */ static async complete( connection: MCPConnection, ref: { type: MCPRefType.PROMPT; name: string } | { type: MCPRefType.RESOURCE; uri: string }, argument: { name: string; value: string } ): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> { try { const result = await connection.client.complete({ argument, ref }); return result.completion; } catch (error) { console.error(`[MCPService] Failed to get completions:`, error); return null; } } /** * * * Resources Operations * * */ /** * List resources from a connection. * @param connection - The MCP connection to use * @param cursor - Optional pagination cursor * @returns Array of available resources and optional next cursor */ static async listResources( connection: MCPConnection, cursor?: string ): Promise<{ resources: MCPResource[]; nextCursor?: string }> { try { const result = await connection.client.listResources(cursor ? { cursor } : undefined); return { nextCursor: result.nextCursor, resources: (result.resources ?? []) as MCPResource[] }; } catch (error) { if (this.isSessionExpiredError(error)) { throw error; } console.warn(`[MCPService][${connection.serverName}] Failed to list resources:`, error); return { resources: [] }; } } /** * List all resources from a connection (handles pagination automatically). * @param connection - The MCP connection to use * @returns Array of all available resources */ static async listAllResources(connection: MCPConnection): Promise { const allResources: MCPResource[] = []; let cursor: string | undefined; do { const result = await this.listResources(connection, cursor); allResources.push(...result.resources); cursor = result.nextCursor; } while (cursor); return allResources; } /** * List resource templates from a connection. * @param connection - The MCP connection to use * @param cursor - Optional pagination cursor * @returns Array of available resource templates and optional next cursor */ static async listResourceTemplates( connection: MCPConnection, cursor?: string ): Promise<{ resourceTemplates: MCPResourceTemplate[]; nextCursor?: string }> { try { const result = await connection.client.listResourceTemplates(cursor ? { cursor } : undefined); return { nextCursor: result.nextCursor, resourceTemplates: (result.resourceTemplates ?? []) as MCPResourceTemplate[] }; } catch (error) { if (this.isSessionExpiredError(error)) { throw error; } console.warn( `[MCPService][${connection.serverName}] Failed to list resource templates:`, error ); return { resourceTemplates: [] }; } } /** * List all resource templates from a connection (handles pagination automatically). * @param connection - The MCP connection to use * @returns Array of all available resource templates */ static async listAllResourceTemplates(connection: MCPConnection): Promise { const allTemplates: MCPResourceTemplate[] = []; let cursor: string | undefined; do { const result = await this.listResourceTemplates(connection, cursor); allTemplates.push(...result.resourceTemplates); cursor = result.nextCursor; } while (cursor); return allTemplates; } /** * Read the contents of a resource. * @param connection - The MCP connection to use * @param uri - The URI of the resource to read * @returns The resource contents */ static async readResource( connection: MCPConnection, uri: string ): Promise { try { const result = await connection.client.readResource({ uri }); return { _meta: result._meta, contents: (result.contents ?? []) as MCPResourceContent[] }; } catch (error) { console.error(`[MCPService][${connection.serverName}] Failed to read resource:`, error); throw error; } } /** * Subscribe to updates for a resource. * The server will send notifications/resources/updated when the resource changes. * @param connection - The MCP connection to use * @param uri - The URI of the resource to subscribe to */ static async subscribeResource(connection: MCPConnection, uri: string): Promise { try { await connection.client.subscribeResource({ uri }); console.log(`[MCPService][${connection.serverName}] Subscribed to resource: ${uri}`); } catch (error) { console.error( `[MCPService][${connection.serverName}] Failed to subscribe to resource:`, error ); throw error; } } /** * Unsubscribe from updates for a resource. * @param connection - The MCP connection to use * @param uri - The URI of the resource to unsubscribe from */ static async unsubscribeResource(connection: MCPConnection, uri: string): Promise { try { await connection.client.unsubscribeResource({ uri }); if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) { console.log(`[MCPService][${connection.serverName}] Unsubscribed from resource: ${uri}`); } } catch (error) { console.error( `[MCPService][${connection.serverName}] Failed to unsubscribe from resource:`, error ); throw error; } } /** * Check if a connection supports resources. * Per MCP spec: presence of the `resources` key (even as empty object `{}`) indicates support. * Empty object means resources are supported but no sub-features (subscribe, listChanged). * * @param connection - The MCP connection to check * @returns Whether the server declares the resources capability */ static supportsResources(connection: MCPConnection): boolean { // Per MCP spec: "Servers that support resources MUST declare the resources capability" // The presence of the key indicates support, even if it's an empty object return connection.serverCapabilities?.resources !== undefined; } /** * Check if a connection supports resource subscriptions. * @param connection - The MCP connection to check * @returns Whether the server supports resource subscriptions */ static supportsResourceSubscriptions(connection: MCPConnection): boolean { return !!connection.serverCapabilities?.resources?.subscribe; } }