From 1623547e2bc947c6d343906bd14f66bc185bacec Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Wed, 28 Jan 2026 18:28:02 +0100 Subject: [PATCH] feat: Integrate Resource Store into Main MCP Store --- .../server/webui/src/lib/stores/mcp.svelte.ts | 384 +++++++++++++++++- 1 file changed, 364 insertions(+), 20 deletions(-) diff --git a/tools/server/webui/src/lib/stores/mcp.svelte.ts b/tools/server/webui/src/lib/stores/mcp.svelte.ts index 2181b59d0f..daa09a5189 100644 --- a/tools/server/webui/src/lib/stores/mcp.svelte.ts +++ b/tools/server/webui/src/lib/stores/mcp.svelte.ts @@ -22,6 +22,7 @@ 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'; @@ -40,7 +41,6 @@ import type { MCPPromptInfo, GetPromptResult, Tool, - Prompt, HealthCheckState, MCPServerSettingsEntry, MCPServerConfig @@ -256,6 +256,14 @@ class MCPStore { 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) @@ -265,6 +273,10 @@ class MCPStore { 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); @@ -346,12 +358,10 @@ class MCPStore { } private async initialize(signature: string, mcpConfig: MCPClientConfig): Promise { - console.log('[MCPStore] Starting initialization...'); this.updateState({ isInitializing: true, error: null }); this.configSignature = signature; const serverEntries = Object.entries(mcpConfig.servers); if (serverEntries.length === 0) { - console.log('[MCPStore] No servers configured'); this.updateState({ isInitializing: false, toolCount: 0, connectedServers: [] }); return false; } @@ -381,7 +391,6 @@ class MCPStore { }) ); if (this.configSignature !== signature) { - console.log('[MCPStore] Config changed during init, aborting'); for (const result of results) { if (result.status === 'fulfilled') await MCPService.disconnect(result.value.connection).catch(console.warn); @@ -420,9 +429,6 @@ class MCPStore { toolCount: this.toolsIndex.size, connectedServers: Array.from(this.connections.keys()) }); - console.log( - `[MCPStore] Initialization complete: ${successCount}/${serverEntries.length} servers, ${this.toolsIndex.size} tools` - ); this.initPromise = null; return true; } @@ -435,19 +441,15 @@ class MCPStore { console.warn(`[MCPStore][${serverName}] Tools list changed error:`, error); return; } - console.log(`[MCPStore][${serverName}] Tools list changed, ${tools?.length ?? 0} tools`); this.handleToolsListChanged(serverName, tools ?? []); } }, prompts: { - onChanged: (error: Error | null, prompts: Prompt[] | null) => { + onChanged: (error: Error | null) => { if (error) { console.warn(`[MCPStore][${serverName}] Prompts list changed error:`, error); return; } - console.log( - `[MCPStore][${serverName}] Prompts list changed, ${prompts?.length ?? 0} prompts` - ); } } }; @@ -472,7 +474,6 @@ class MCPStore { acquireConnection(): void { this.activeFlowCount++; - console.log(`[MCPStore] Connection acquired (active flows: ${this.activeFlowCount})`); } /** @@ -482,9 +483,7 @@ class MCPStore { */ async releaseConnection(shutdownIfUnused = false): Promise { this.activeFlowCount = Math.max(0, this.activeFlowCount - 1); - console.log(`[MCPStore] Connection released (active flows: ${this.activeFlowCount})`); if (shutdownIfUnused && this.activeFlowCount === 0) { - console.log('[MCPStore] No active flows, initiating lazy disconnect...'); await this.shutdown(); } } @@ -499,7 +498,6 @@ class MCPStore { this.initPromise = null; } if (this.connections.size === 0) return; - console.log(`[MCPStore] Shutting down ${this.connections.size} connections...`); await Promise.all( Array.from(this.connections.values()).map((conn) => MCPService.disconnect(conn).catch((error) => @@ -511,7 +509,6 @@ class MCPStore { this.toolsIndex.clear(); this.configSignature = null; this.updateState({ isInitializing: false, error: null, toolCount: 0, connectedServers: [] }); - console.log('[MCPStore] Shutdown complete'); } getToolDefinitionsForLLM(): OpenAIToolDefinition[] { @@ -713,7 +710,7 @@ class MCPStore { const BATCH_SIZE = 5; for (let i = 0; i < serversToCheck.length; i += BATCH_SIZE) { const batch = serversToCheck.slice(i, i + BATCH_SIZE); - await Promise.all(batch.map((server) => this.runHealthCheck(server, promoteToActive))); + await Promise.allSettled(batch.map((server) => this.runHealthCheck(server, promoteToActive))); } } @@ -757,7 +754,6 @@ class MCPStore { connectionTimeMs: existingConnection.connectionTimeMs, logs: [] }); - console.log(`[MCPStore] Reused existing connection for health check: ${server.id}`); return; } catch (error) { console.warn( @@ -786,6 +782,7 @@ class MCPStore { }); const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000); const headers = this.parseHeaders(server.headers); + try { const connection = await MCPService.connect( server.id, @@ -828,7 +825,13 @@ class MCPStore { connectionTimeMs: connection.connectionTimeMs, logs }); - await MCPService.disconnect(connection); + + // 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({ @@ -883,6 +886,340 @@ class MCPStore { } 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(); @@ -895,3 +1232,10 @@ 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();