/** * mcpStore - Reactive State Store for MCP Operations * * Implements the "Host" role in MCP architecture, coordinating multiple server * connections and providing a unified interface for tool operations. * * **Architecture & Relationships:** * - **MCPService**: Stateless protocol layer (transport, connect, callTool) * - **mcpStore** (this): Reactive state + business logic * * **Key Responsibilities:** * - Lifecycle management (initialize, shutdown) * - Multi-server coordination * - Tool name conflict detection and resolution * - OpenAI-compatible tool definition generation * - Automatic tool-to-server routing * - Health checks * * @see MCPService in services/mcp.service.ts for protocol operations */ import { browser } from '$app/environment'; import { MCPService } from '$lib/services/mcp.service'; import { config, settingsStore } from '$lib/stores/settings.svelte'; import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte'; import { parseMcpServerSettings, detectMcpTransportFromUrl } from '$lib/utils'; import { MCPConnectionPhase, MCPLogLevel, HealthCheckStatus } from '$lib/enums'; import { DEFAULT_MCP_CONFIG, MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp'; import type { MCPToolCall, OpenAIToolDefinition, ServerStatus, ToolExecutionResult, MCPClientConfig, MCPConnection, HealthCheckParams, ServerCapabilities, ClientCapabilities, MCPCapabilitiesInfo, MCPConnectionLog, MCPPromptInfo, GetPromptResult, Tool, HealthCheckState, MCPServerSettingsEntry, MCPServerConfig } from '$lib/types'; import type { ListChangedHandlers } from '@modelcontextprotocol/sdk/types.js'; import type { McpServerOverride } from '$lib/types/database'; import type { SettingsConfigType } from '$lib/types/settings'; function generateMcpServerId(id: unknown, index: number): string { if (typeof id === 'string' && id.trim()) return id.trim(); return `${MCP_SERVER_ID_PREFIX}${index + 1}`; } function parseServerSettings(rawServers: unknown): MCPServerSettingsEntry[] { if (!rawServers) return []; let parsed: unknown; if (typeof rawServers === 'string') { const trimmed = rawServers.trim(); if (!trimmed) return []; try { parsed = JSON.parse(trimmed); } catch (error) { console.warn('[MCP] Failed to parse mcpServers JSON:', error); return []; } } else { parsed = rawServers; } if (!Array.isArray(parsed)) return []; return parsed.map((entry, index) => { const url = typeof entry?.url === 'string' ? entry.url.trim() : ''; const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined; return { id: generateMcpServerId((entry as { id?: unknown })?.id, index), enabled: Boolean((entry as { enabled?: unknown })?.enabled), url, name: (entry as { name?: string })?.name, requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds, headers: headers || undefined } satisfies MCPServerSettingsEntry; }); } function buildServerConfig( entry: MCPServerSettingsEntry, connectionTimeoutMs = DEFAULT_MCP_CONFIG.connectionTimeoutMs ): MCPServerConfig | undefined { if (!entry?.url) return undefined; let headers: Record | undefined; if (entry.headers) { try { const parsed = JSON.parse(entry.headers); if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) headers = parsed as Record; } catch { console.warn('[MCP] Failed to parse custom headers JSON:', entry.headers); } } return { url: entry.url, transport: detectMcpTransportFromUrl(entry.url), handshakeTimeoutMs: connectionTimeoutMs, requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000), headers }; } function checkServerEnabled( server: MCPServerSettingsEntry, perChatOverrides?: McpServerOverride[] ): boolean { if (!server.enabled) return false; if (perChatOverrides) { const override = perChatOverrides.find((o) => o.serverId === server.id); return override?.enabled ?? false; } return false; } function buildMcpClientConfigInternal( cfg: SettingsConfigType, perChatOverrides?: McpServerOverride[] ): MCPClientConfig | undefined { const rawServers = parseServerSettings(cfg.mcpServers); if (!rawServers.length) return undefined; const servers: Record = {}; for (const [index, entry] of rawServers.entries()) { if (!checkServerEnabled(entry, perChatOverrides)) continue; const normalized = buildServerConfig(entry); if (normalized) servers[generateMcpServerId(entry.id, index)] = normalized; } if (Object.keys(servers).length === 0) return undefined; return { protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion, capabilities: DEFAULT_MCP_CONFIG.capabilities, clientInfo: DEFAULT_MCP_CONFIG.clientInfo, requestTimeoutMs: Math.round(DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000), servers }; } function buildCapabilitiesInfo( serverCaps?: ServerCapabilities, clientCaps?: ClientCapabilities ): MCPCapabilitiesInfo { return { server: { tools: serverCaps?.tools ? { listChanged: serverCaps.tools.listChanged } : undefined, prompts: serverCaps?.prompts ? { listChanged: serverCaps.prompts.listChanged } : undefined, resources: serverCaps?.resources ? { subscribe: serverCaps.resources.subscribe, listChanged: serverCaps.resources.listChanged } : undefined, logging: !!serverCaps?.logging, completions: !!serverCaps?.completions, tasks: !!serverCaps?.tasks }, client: { roots: clientCaps?.roots ? { listChanged: clientCaps.roots.listChanged } : undefined, sampling: !!clientCaps?.sampling, elicitation: clientCaps?.elicitation ? { form: !!clientCaps.elicitation.form, url: !!clientCaps.elicitation.url } : undefined, tasks: !!clientCaps?.tasks } }; } export function buildMcpClientConfig( cfg: SettingsConfigType, perChatOverrides?: McpServerOverride[] ): MCPClientConfig | undefined { return buildMcpClientConfigInternal(cfg, perChatOverrides); } class MCPStore { private _isInitializing = $state(false); private _error = $state(null); private _toolCount = $state(0); private _connectedServers = $state([]); private _healthChecks = $state>({}); private connections = new Map(); private toolsIndex = new Map(); private configSignature: string | null = null; private initPromise: Promise | null = null; private activeFlowCount = 0; get isInitializing(): boolean { return this._isInitializing; } get isInitialized(): boolean { return this.connections.size > 0; } get error(): string | null { return this._error; } get toolCount(): number { return this._toolCount; } get connectedServerCount(): number { return this._connectedServers.length; } get connectedServerNames(): string[] { return this._connectedServers; } get isEnabled(): boolean { const mcpConfig = buildMcpClientConfigInternal(config()); return ( mcpConfig !== null && mcpConfig !== undefined && Object.keys(mcpConfig.servers).length > 0 ); } get availableTools(): string[] { return Array.from(this.toolsIndex.keys()); } private updateState(state: { isInitializing?: boolean; error?: string | null; toolCount?: number; connectedServers?: string[]; }): void { if (state.isInitializing !== undefined) this._isInitializing = state.isInitializing; if (state.error !== undefined) this._error = state.error; if (state.toolCount !== undefined) this._toolCount = state.toolCount; if (state.connectedServers !== undefined) this._connectedServers = state.connectedServers; } updateHealthCheck(serverId: string, state: HealthCheckState): void { this._healthChecks = { ...this._healthChecks, [serverId]: state }; } getHealthCheckState(serverId: string): HealthCheckState { return this._healthChecks[serverId] ?? { status: 'idle' }; } hasHealthCheck(serverId: string): boolean { return serverId in this._healthChecks && this._healthChecks[serverId].status !== 'idle'; } clearHealthCheck(serverId: string): void { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { [serverId]: _removed, ...rest } = this._healthChecks; this._healthChecks = rest; } clearAllHealthChecks(): void { this._healthChecks = {}; } clearError(): void { this._error = null; } getServers(): MCPServerSettingsEntry[] { return parseMcpServerSettings(config().mcpServers); } /** * Get all active MCP connections. * @returns Map of server names to connections */ getConnections(): Map { return this.connections; } getServerLabel(server: MCPServerSettingsEntry): string { const healthState = this.getHealthCheckState(server.id); if (healthState?.status === HealthCheckStatus.SUCCESS) return ( healthState.serverInfo?.title || healthState.serverInfo?.name || server.name || server.url ); return server.url; } getServerById(serverId: string): MCPServerSettingsEntry | undefined { return this.getServers().find((s) => s.id === serverId); } isAnyServerLoading(): boolean { return this.getServers().some((s) => { const state = this.getHealthCheckState(s.id); return ( state.status === HealthCheckStatus.IDLE || state.status === HealthCheckStatus.CONNECTING ); }); } getServersSorted(): MCPServerSettingsEntry[] { const servers = this.getServers(); if (this.isAnyServerLoading()) return servers; return [...servers].sort((a, b) => this.getServerLabel(a).localeCompare(this.getServerLabel(b)) ); } addServer( serverData: Omit & { id?: string } ): void { const servers = this.getServers(); const newServer: MCPServerSettingsEntry = { id: serverData.id || (crypto.randomUUID ? crypto.randomUUID() : `server-${Date.now()}`), enabled: serverData.enabled, url: serverData.url.trim(), name: serverData.name, headers: serverData.headers?.trim() || undefined, requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds }; settingsStore.updateConfig('mcpServers', JSON.stringify([...servers, newServer])); } updateServer(id: string, updates: Partial): void { const servers = this.getServers(); settingsStore.updateConfig( 'mcpServers', JSON.stringify( servers.map((server) => (server.id === id ? { ...server, ...updates } : server)) ) ); } removeServer(id: string): void { const servers = this.getServers(); settingsStore.updateConfig('mcpServers', JSON.stringify(servers.filter((s) => s.id !== id))); this.clearHealthCheck(id); } hasAvailableServers(): boolean { return parseMcpServerSettings(config().mcpServers).some((s) => s.enabled && s.url.trim()); } hasEnabledServers(perChatOverrides?: McpServerOverride[]): boolean { return Boolean(buildMcpClientConfigInternal(config(), perChatOverrides)); } getEnabledServersForConversation( perChatOverrides?: McpServerOverride[] ): MCPServerSettingsEntry[] { if (!perChatOverrides?.length) return []; return this.getServers().filter((server) => { if (!server.enabled) return false; const override = perChatOverrides.find((o) => o.serverId === server.id); return override?.enabled ?? false; }); } async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise { if (!browser) return false; const mcpConfig = buildMcpClientConfigInternal(config(), perChatOverrides); const signature = mcpConfig ? JSON.stringify(mcpConfig) : null; if (!signature) { await this.shutdown(); return false; } if (this.isInitialized && this.configSignature === signature) return true; if (this.initPromise && this.configSignature === signature) return this.initPromise; if (this.connections.size > 0 || this.initPromise) await this.shutdown(); return this.initialize(signature, mcpConfig!); } private async initialize(signature: string, mcpConfig: MCPClientConfig): Promise { this.updateState({ isInitializing: true, error: null }); this.configSignature = signature; const serverEntries = Object.entries(mcpConfig.servers); if (serverEntries.length === 0) { this.updateState({ isInitializing: false, toolCount: 0, connectedServers: [] }); return false; } this.initPromise = this.doInitialize(signature, mcpConfig, serverEntries); return this.initPromise; } private async doInitialize( signature: string, mcpConfig: MCPClientConfig, serverEntries: [string, MCPClientConfig['servers'][string]][] ): Promise { const clientInfo = mcpConfig.clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo; const capabilities = mcpConfig.capabilities ?? DEFAULT_MCP_CONFIG.capabilities; const results = await Promise.allSettled( serverEntries.map(async ([name, serverConfig]) => { const listChangedHandlers = this.createListChangedHandlers(name); const connection = await MCPService.connect( name, serverConfig, clientInfo, capabilities, undefined, listChangedHandlers ); return { name, connection }; }) ); if (this.configSignature !== signature) { for (const result of results) { if (result.status === 'fulfilled') await MCPService.disconnect(result.value.connection).catch(console.warn); } return false; } for (const result of results) { if (result.status === 'fulfilled') { const { name, connection } = result.value; this.connections.set(name, connection); for (const tool of connection.tools) { if (this.toolsIndex.has(tool.name)) console.warn( `[MCPStore] Tool name conflict: "${tool.name}" exists in "${this.toolsIndex.get(tool.name)}" and "${name}". Using tool from "${name}".` ); this.toolsIndex.set(tool.name, name); } } else { console.error(`[MCPStore] Failed to connect:`, result.reason); } } const successCount = this.connections.size; if (successCount === 0 && serverEntries.length > 0) { this.updateState({ isInitializing: false, error: 'All MCP server connections failed', toolCount: 0, connectedServers: [] }); this.initPromise = null; return false; } this.updateState({ isInitializing: false, error: null, toolCount: this.toolsIndex.size, connectedServers: Array.from(this.connections.keys()) }); this.initPromise = null; return true; } private createListChangedHandlers(serverName: string): ListChangedHandlers { return { tools: { onChanged: (error: Error | null, tools: Tool[] | null) => { if (error) { console.warn(`[MCPStore][${serverName}] Tools list changed error:`, error); return; } this.handleToolsListChanged(serverName, tools ?? []); } }, prompts: { onChanged: (error: Error | null) => { if (error) { console.warn(`[MCPStore][${serverName}] Prompts list changed error:`, error); return; } } } }; } private handleToolsListChanged(serverName: string, tools: Tool[]): void { const connection = this.connections.get(serverName); if (!connection) return; for (const [toolName, ownerServer] of this.toolsIndex.entries()) { if (ownerServer === serverName) this.toolsIndex.delete(toolName); } connection.tools = tools; for (const tool of tools) { if (this.toolsIndex.has(tool.name)) console.warn( `[MCPStore] Tool name conflict after list change: "${tool.name}" exists in "${this.toolsIndex.get(tool.name)}" and "${serverName}". Using tool from "${serverName}".` ); this.toolsIndex.set(tool.name, serverName); } this.updateState({ toolCount: this.toolsIndex.size }); } acquireConnection(): void { this.activeFlowCount++; } /** * Release a connection reference. * By default, keeps connections alive for reuse (shutdownIfUnused=false). * MCP spec encourages long-lived sessions to avoid reconnection overhead. */ async releaseConnection(shutdownIfUnused = false): Promise { this.activeFlowCount = Math.max(0, this.activeFlowCount - 1); if (shutdownIfUnused && this.activeFlowCount === 0) { await this.shutdown(); } } getActiveFlowCount(): number { return this.activeFlowCount; } async shutdown(): Promise { if (this.initPromise) { await this.initPromise.catch(() => {}); this.initPromise = null; } if (this.connections.size === 0) return; await Promise.all( Array.from(this.connections.values()).map((conn) => MCPService.disconnect(conn).catch((error) => console.warn(`[MCPStore] Error disconnecting ${conn.serverName}:`, error) ) ) ); this.connections.clear(); this.toolsIndex.clear(); this.configSignature = null; this.updateState({ isInitializing: false, error: null, toolCount: 0, connectedServers: [] }); } getToolDefinitionsForLLM(): OpenAIToolDefinition[] { const tools: OpenAIToolDefinition[] = []; for (const connection of this.connections.values()) { for (const tool of connection.tools) { const rawSchema = (tool.inputSchema as Record) ?? { type: 'object', properties: {}, required: [] }; tools.push({ type: 'function' as const, function: { name: tool.name, description: tool.description, parameters: this.normalizeSchemaProperties(rawSchema) } }); } } return tools; } private normalizeSchemaProperties(schema: Record): Record { if (!schema || typeof schema !== 'object') return schema; const normalized = { ...schema }; 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 }; 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'; } if (normalizedProp.properties) Object.assign( normalizedProp, this.normalizeSchemaProperties(normalizedProp as Record) ); if (normalizedProp.items && typeof normalizedProp.items === 'object') normalizedProp.items = this.normalizeSchemaProperties( normalizedProp.items as Record ); normalizedProps[key] = normalizedProp; } normalized.properties = normalizedProps; } return normalized; } getToolNames(): string[] { return Array.from(this.toolsIndex.keys()); } hasTool(toolName: string): boolean { return this.toolsIndex.has(toolName); } getToolServer(toolName: string): string | undefined { return this.toolsIndex.get(toolName); } hasPromptsSupport(): boolean { for (const connection of this.connections.values()) { if (connection.serverCapabilities?.prompts) return true; } return false; } async getAllPrompts(): Promise { const results: MCPPromptInfo[] = []; for (const [serverName, connection] of this.connections) { if (!connection.serverCapabilities?.prompts) continue; const prompts = await MCPService.listPrompts(connection); for (const prompt of prompts) { results.push({ name: prompt.name, description: prompt.description, title: prompt.title, serverName, arguments: prompt.arguments?.map((arg) => ({ name: arg.name, description: arg.description, required: arg.required })) }); } } return results; } async getPrompt( serverName: string, promptName: string, args?: Record ): Promise { const connection = this.connections.get(serverName); if (!connection) throw new Error(`Server "${serverName}" not found for prompt "${promptName}"`); return MCPService.getPrompt(connection, promptName, args); } async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise { const toolName = toolCall.function.name; const serverName = this.toolsIndex.get(toolName); if (!serverName) throw new Error(`Unknown tool: ${toolName}`); const connection = this.connections.get(serverName); if (!connection) throw new Error(`Server "${serverName}" is not connected`); const args = this.parseToolArguments(toolCall.function.arguments); return MCPService.callTool(connection, { name: toolName, arguments: args }, signal); } async executeToolByName( toolName: string, args: Record, signal?: AbortSignal ): Promise { const serverName = this.toolsIndex.get(toolName); if (!serverName) throw new Error(`Unknown tool: ${toolName}`); const connection = this.connections.get(serverName); if (!connection) throw new Error(`Server "${serverName}" is not connected`); return MCPService.callTool(connection, { name: toolName, arguments: args }, signal); } private parseToolArguments(args: string | Record): Record { if (typeof args === 'string') { const trimmed = args.trim(); if (trimmed === '') return {}; try { const parsed = JSON.parse(trimmed); if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw new Error( `Tool arguments must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}` ); return parsed as Record; } catch (error) { throw new Error(`Failed to parse tool arguments as JSON: ${(error as Error).message}`); } } if (typeof args === 'object' && args !== null && !Array.isArray(args)) return args; throw new Error(`Invalid tool arguments type: ${typeof args}`); } async getPromptCompletions( serverName: string, promptName: string, argumentName: string, argumentValue: string ): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> { const connection = this.connections.get(serverName); if (!connection) { console.warn(`[MCPStore] Server "${serverName}" is not connected`); return null; } if (!connection.serverCapabilities?.completions) return null; return MCPService.complete( connection, { type: 'ref/prompt', name: promptName }, { name: argumentName, value: argumentValue } ); } private parseHeaders(headersJson?: string): Record | undefined { if (!headersJson?.trim()) return undefined; try { const parsed = JSON.parse(headersJson); if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) return parsed as Record; } catch { console.warn('[MCPStore] Failed to parse custom headers JSON:', headersJson); } return undefined; } async runHealthChecksForServers( servers: { id: string; enabled: boolean; url: string; requestTimeoutSeconds: number; headers?: string; }[], skipIfChecked = true, promoteToActive = false ): Promise { const serversToCheck = skipIfChecked ? servers.filter((s) => !this.hasHealthCheck(s.id) && s.url.trim()) : servers.filter((s) => s.url.trim()); if (serversToCheck.length === 0) return; const BATCH_SIZE = 5; for (let i = 0; i < serversToCheck.length; i += BATCH_SIZE) { const batch = serversToCheck.slice(i, i + BATCH_SIZE); await Promise.allSettled(batch.map((server) => this.runHealthCheck(server, promoteToActive))); } } /** * Check if a server already has an active connection that can be reused. * Returns the existing connection if available. */ getExistingConnection(serverId: string): MCPConnection | undefined { return this.connections.get(serverId); } /** * Run a health check for a server. * If the server already has an active connection, reuses it instead of creating a new one. * If promoteToActive is true and server is enabled, the connection will be kept * and promoted to an active connection instead of being disconnected. */ async runHealthCheck(server: HealthCheckParams, promoteToActive = false): Promise { // Check if we already have an active connection for this server const existingConnection = this.connections.get(server.id); if (existingConnection) { // Reuse existing connection - just refresh tools list try { const tools = await MCPService.listTools(existingConnection); const capabilities = buildCapabilitiesInfo( existingConnection.serverCapabilities, existingConnection.clientCapabilities ); this.updateHealthCheck(server.id, { status: HealthCheckStatus.SUCCESS, tools: tools.map((tool) => ({ name: tool.name, description: tool.description, title: tool.title })), serverInfo: existingConnection.serverInfo, capabilities, transportType: existingConnection.transportType, protocolVersion: existingConnection.protocolVersion, instructions: existingConnection.instructions, connectionTimeMs: existingConnection.connectionTimeMs, logs: [] }); return; } catch (error) { console.warn( `[MCPStore] Failed to reuse connection for ${server.id}, creating new one:`, error ); // Connection may be stale, remove it and create new one this.connections.delete(server.id); } } const trimmedUrl = server.url.trim(); const logs: MCPConnectionLog[] = []; let currentPhase: MCPConnectionPhase = MCPConnectionPhase.IDLE; if (!trimmedUrl) { this.updateHealthCheck(server.id, { status: HealthCheckStatus.ERROR, message: 'Please enter a server URL first.', logs: [] }); return; } this.updateHealthCheck(server.id, { status: HealthCheckStatus.CONNECTING, phase: MCPConnectionPhase.TRANSPORT_CREATING, logs: [] }); const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000); const headers = this.parseHeaders(server.headers); try { const connection = await MCPService.connect( server.id, { url: trimmedUrl, transport: detectMcpTransportFromUrl(trimmedUrl), handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs, requestTimeoutMs: timeoutMs, headers }, DEFAULT_MCP_CONFIG.clientInfo, DEFAULT_MCP_CONFIG.capabilities, (phase, log) => { currentPhase = phase; logs.push(log); this.updateHealthCheck(server.id, { status: HealthCheckStatus.CONNECTING, phase, logs: [...logs] }); } ); const tools = connection.tools.map((tool) => ({ name: tool.name, description: tool.description, title: tool.title })); const capabilities = buildCapabilitiesInfo( connection.serverCapabilities, connection.clientCapabilities ); this.updateHealthCheck(server.id, { status: HealthCheckStatus.SUCCESS, tools, serverInfo: connection.serverInfo, capabilities, transportType: connection.transportType, protocolVersion: connection.protocolVersion, instructions: connection.instructions, connectionTimeMs: connection.connectionTimeMs, logs }); // Promote to active connection or disconnect if (promoteToActive && server.enabled) { this.promoteHealthCheckToConnection(server.id, connection); } else { await MCPService.disconnect(connection); } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error occurred'; logs.push({ timestamp: new Date(), phase: MCPConnectionPhase.ERROR, message: `Connection failed: ${message}`, level: MCPLogLevel.ERROR }); this.updateHealthCheck(server.id, { status: HealthCheckStatus.ERROR, message, phase: currentPhase, logs }); } } /** * Promote a health check connection to an active connection. * This avoids the need to reconnect when the server is needed for agentic flows. */ private promoteHealthCheckToConnection(serverId: string, connection: MCPConnection): void { // Register tools from the connection for (const tool of connection.tools) { if (this.toolsIndex.has(tool.name)) { console.warn( `[MCPStore] Tool name conflict during promotion: "${tool.name}" exists in "${this.toolsIndex.get(tool.name)}" and "${serverId}". Using tool from "${serverId}".` ); } this.toolsIndex.set(tool.name, serverId); } // Add to active connections this.connections.set(serverId, connection); // Update state this.updateState({ toolCount: this.toolsIndex.size, connectedServers: Array.from(this.connections.keys()) }); } getServersStatus(): ServerStatus[] { const statuses: ServerStatus[] = []; for (const [name, connection] of this.connections) { statuses.push({ name, isConnected: true, toolCount: connection.tools.length, error: undefined }); } return statuses; } /** * Get aggregated server instructions from all connected servers. * Returns an array of { serverName, serverTitle, instructions } objects. */ getServerInstructions(): Array<{ serverName: string; serverTitle?: string; instructions: string; }> { const results: Array<{ serverName: string; serverTitle?: string; instructions: string }> = []; for (const [serverName, connection] of this.connections) { if (connection.instructions) { results.push({ serverName, serverTitle: connection.serverInfo?.title || connection.serverInfo?.name, instructions: connection.instructions }); } } return results; } /** * Get server instructions from health check results (for display before active connection). * Useful for showing instructions in settings UI. */ getHealthCheckInstructions(): Array<{ serverId: string; serverTitle?: string; instructions: string; }> { const results: Array<{ serverId: string; serverTitle?: string; instructions: string }> = []; for (const [serverId, state] of Object.entries(this._healthChecks)) { if (state.status === HealthCheckStatus.SUCCESS && state.instructions) { results.push({ serverId, serverTitle: state.serverInfo?.title || state.serverInfo?.name, instructions: state.instructions }); } } return results; } /** * Check if any connected server has instructions. */ hasServerInstructions(): boolean { for (const connection of this.connections.values()) { if (connection.instructions) return true; } return false; } /** * * * Resources Operations * * */ /** * Check if any enabled server with successful health check supports resources. * Uses health check state since servers may not have active connections until * the user actually sends a message or uses prompts. */ hasResourcesCapability(): boolean { // Check health check states for servers with resources capability for (const state of Object.values(this._healthChecks)) { if ( state.status === HealthCheckStatus.SUCCESS && state.capabilities?.server?.resources !== undefined ) { return true; } } // Also check active connections as fallback for (const connection of this.connections.values()) { if (MCPService.supportsResources(connection)) { return true; } } return false; } /** * Get list of servers that support resources. * Checks active connections first, then health check state as fallback. */ getServersWithResources(): string[] { const servers: string[] = []; // Check active connections for (const [name, connection] of this.connections) { if (MCPService.supportsResources(connection) && !servers.includes(name)) { servers.push(name); } } // Also check health check states for servers not yet connected for (const [serverId, state] of Object.entries(this._healthChecks)) { if ( !servers.includes(serverId) && state.status === HealthCheckStatus.SUCCESS && state.capabilities?.server?.resources !== undefined ) { servers.push(serverId); } } return servers; } /** * Fetch resources from all connected servers that support them. * Updates mcpResourceStore with the results. * @param forceRefresh - If true, bypass cache and fetch fresh data */ async fetchAllResources(forceRefresh: boolean = false): Promise { const serversWithResources = this.getServersWithResources(); if (serversWithResources.length === 0) { return; } // Check if we have cached resources and they're recent (unless force refresh) if (!forceRefresh) { const allServersCached = serversWithResources.every((serverName) => { const serverRes = mcpResourceStore.getServerResources(serverName); if (!serverRes || !serverRes.lastFetched) return false; // Cache is valid for 5 minutes const age = Date.now() - serverRes.lastFetched.getTime(); return age < 5 * 60 * 1000; }); if (allServersCached) { console.log('[MCPStore] Using cached resources'); return; } } mcpResourceStore.setLoading(true); try { await Promise.all( serversWithResources.map((serverName) => this.fetchServerResources(serverName)) ); } finally { mcpResourceStore.setLoading(false); } } /** * Fetch resources from a specific server. * Updates mcpResourceStore with the results. */ async fetchServerResources(serverName: string): Promise { const connection = this.connections.get(serverName); if (!connection) { console.warn(`[MCPStore] No connection found for server: ${serverName}`); return; } if (!MCPService.supportsResources(connection)) { return; } mcpResourceStore.setServerLoading(serverName, true); try { const [resources, templates] = await Promise.all([ MCPService.listAllResources(connection), MCPService.listAllResourceTemplates(connection) ]); mcpResourceStore.setServerResources(serverName, resources, templates); } catch (error) { const message = error instanceof Error ? error.message : String(error); mcpResourceStore.setServerError(serverName, message); console.error(`[MCPStore][${serverName}] Failed to fetch resources:`, error); } } /** * Read resource content from a server. * Caches the result in mcpResourceStore. */ async readResource(uri: string): Promise { // Check cache first const cached = mcpResourceStore.getCachedContent(uri); if (cached) { return cached.content; } // Find which server has this resource const serverName = mcpResourceStore.findServerForUri(uri); if (!serverName) { console.error(`[MCPStore] No server found for resource URI: ${uri}`); return null; } const connection = this.connections.get(serverName); if (!connection) { console.error(`[MCPStore] No connection found for server: ${serverName}`); return null; } try { const result = await MCPService.readResource(connection, uri); const resourceInfo = mcpResourceStore.findResourceByUri(uri); if (resourceInfo) { mcpResourceStore.cacheResourceContent(resourceInfo, result.contents); } return result.contents; } catch (error) { console.error(`[MCPStore] Failed to read resource ${uri}:`, error); return null; } } /** * Subscribe to resource updates. */ async subscribeToResource(uri: string): Promise { const serverName = mcpResourceStore.findServerForUri(uri); if (!serverName) { console.error(`[MCPStore] No server found for resource URI: ${uri}`); return false; } const connection = this.connections.get(serverName); if (!connection) { console.error(`[MCPStore] No connection found for server: ${serverName}`); return false; } if (!MCPService.supportsResourceSubscriptions(connection)) { return false; } try { await MCPService.subscribeResource(connection, uri); mcpResourceStore.addSubscription(uri, serverName); return true; } catch (error) { console.error(`[MCPStore] Failed to subscribe to resource ${uri}:`, error); return false; } } /** * Unsubscribe from resource updates. */ async unsubscribeFromResource(uri: string): Promise { const serverName = mcpResourceStore.findServerForUri(uri); if (!serverName) { console.error(`[MCPStore] No server found for resource URI: ${uri}`); return false; } const connection = this.connections.get(serverName); if (!connection) { console.error(`[MCPStore] No connection found for server: ${serverName}`); return false; } try { await MCPService.unsubscribeResource(connection, uri); mcpResourceStore.removeSubscription(uri); return true; } catch (error) { console.error(`[MCPStore] Failed to unsubscribe from resource ${uri}:`, error); return false; } } /** * Add a resource as attachment to chat context. * Automatically fetches content if not cached. */ async attachResource(uri: string): Promise { const resourceInfo = mcpResourceStore.findResourceByUri(uri); if (!resourceInfo) { console.error(`[MCPStore] Resource not found: ${uri}`); return null; } // Check if already attached if (mcpResourceStore.isAttached(uri)) { return null; } // Add attachment (initially loading) const attachment = mcpResourceStore.addAttachment(resourceInfo); // Fetch content try { const content = await this.readResource(uri); if (content) { mcpResourceStore.updateAttachmentContent(attachment.id, content); } else { mcpResourceStore.updateAttachmentError(attachment.id, 'Failed to read resource'); } } catch (error) { const message = error instanceof Error ? error.message : String(error); mcpResourceStore.updateAttachmentError(attachment.id, message); } return mcpResourceStore.getAttachment(attachment.id) ?? null; } /** * Remove a resource attachment from chat context. */ removeResourceAttachment(attachmentId: string): void { mcpResourceStore.removeAttachment(attachmentId); } /** * Clear all resource attachments. */ clearResourceAttachments(): void { mcpResourceStore.clearAttachments(); } /** * Get formatted resource context for chat. */ getResourceContextForChat(): string { return mcpResourceStore.formatAttachmentsForContext(); } } export const mcpStore = new MCPStore(); export const mcpIsInitializing = () => mcpStore.isInitializing; export const mcpIsInitialized = () => mcpStore.isInitialized; export const mcpError = () => mcpStore.error; export const mcpIsEnabled = () => mcpStore.isEnabled; export const mcpAvailableTools = () => mcpStore.availableTools; export const mcpConnectedServerCount = () => mcpStore.connectedServerCount; export const mcpConnectedServerNames = () => mcpStore.connectedServerNames; export const mcpToolCount = () => mcpStore.toolCount; export const mcpServerInstructions = () => mcpStore.getServerInstructions(); export const mcpHasServerInstructions = () => mcpStore.hasServerInstructions(); // Resources exports export const mcpHasResourcesCapability = () => mcpStore.hasResourcesCapability(); export const mcpServersWithResources = () => mcpStore.getServersWithResources(); export const mcpResourceContext = () => mcpStore.getResourceContextForChat();