From dc2076a77cbf9d7080ef7e62459b3f98dde1661d Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Wed, 28 Jan 2026 18:28:02 +0100 Subject: [PATCH] feat: MCP Resources Svelte Store --- .../src/lib/stores/mcp-resources.svelte.ts | 537 ++++++++++++++++++ 1 file changed, 537 insertions(+) create mode 100644 tools/server/webui/src/lib/stores/mcp-resources.svelte.ts diff --git a/tools/server/webui/src/lib/stores/mcp-resources.svelte.ts b/tools/server/webui/src/lib/stores/mcp-resources.svelte.ts new file mode 100644 index 0000000000..d83b7b2ce2 --- /dev/null +++ b/tools/server/webui/src/lib/stores/mcp-resources.svelte.ts @@ -0,0 +1,537 @@ +/** + * mcpResourceStore - Reactive State Store for MCP Resources + * + * Manages MCP protocol resources: + * - Resource discovery and listing per server + * - Resource content caching + * - Resource subscriptions + * - Resource attachments for chat context + * + * @see MCP Protocol Specification: https://modelcontextprotocol.io/specification/2025-06-18/server/resources + */ + +import { SvelteMap } from 'svelte/reactivity'; +import type { + MCPResource, + MCPResourceTemplate, + MCPResourceContent, + MCPResourceInfo, + MCPResourceTemplateInfo, + MCPCachedResource, + MCPResourceAttachment, + MCPResourceSubscription, + MCPServerResources +} from '$lib/types'; + +const MAX_CACHED_RESOURCES = 50; +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +function generateAttachmentId(): string { + return `res-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +} + +class MCPResourceStore { + private _serverResources = $state>(new SvelteMap()); + private _cachedResources = $state>(new SvelteMap()); + private _subscriptions = $state>(new SvelteMap()); + private _attachments = $state([]); + private _isLoading = $state(false); + + get serverResources(): Map { + return this._serverResources; + } + + get cachedResources(): Map { + return this._cachedResources; + } + + get subscriptions(): Map { + return this._subscriptions; + } + + get attachments(): MCPResourceAttachment[] { + return this._attachments; + } + + get isLoading(): boolean { + return this._isLoading; + } + + get totalResourceCount(): number { + let count = 0; + for (const serverRes of this._serverResources.values()) { + count += serverRes.resources.length; + } + return count; + } + + get totalTemplateCount(): number { + let count = 0; + for (const serverRes of this._serverResources.values()) { + count += serverRes.templates.length; + } + return count; + } + + get attachmentCount(): number { + return this._attachments.length; + } + + get hasAttachments(): boolean { + return this._attachments.length > 0; + } + + /** + * + * + * Server Resources Management + * + * + */ + + /** + * Set resources for a server (called after listResources) + */ + setServerResources( + serverName: string, + resources: MCPResource[], + templates: MCPResourceTemplate[] + ): void { + this._serverResources.set(serverName, { + serverName, + resources, + templates, + lastFetched: new Date(), + loading: false, + error: undefined + }); + console.log( + `[MCPResources][${serverName}] Set ${resources.length} resources, ${templates.length} templates` + ); + } + + /** + * Set loading state for a server's resources + */ + setServerLoading(serverName: string, loading: boolean): void { + const existing = this._serverResources.get(serverName); + if (existing) { + this._serverResources.set(serverName, { ...existing, loading }); + } else { + this._serverResources.set(serverName, { + serverName, + resources: [], + templates: [], + loading, + error: undefined + }); + } + } + + /** + * Set error state for a server's resources + */ + setServerError(serverName: string, error: string): void { + const existing = this._serverResources.get(serverName); + if (existing) { + this._serverResources.set(serverName, { ...existing, loading: false, error }); + } else { + this._serverResources.set(serverName, { + serverName, + resources: [], + templates: [], + loading: false, + error + }); + } + } + + /** + * Get resources for a specific server + */ + getServerResources(serverName: string): MCPServerResources | undefined { + return this._serverResources.get(serverName); + } + + /** + * Get all resources as MCPResourceInfo array (flattened with server names) + */ + getAllResourceInfos(): MCPResourceInfo[] { + const result: MCPResourceInfo[] = []; + for (const [serverName, serverRes] of this._serverResources) { + for (const resource of serverRes.resources) { + result.push({ + uri: resource.uri, + name: resource.name, + title: resource.title, + description: resource.description, + mimeType: resource.mimeType, + serverName, + annotations: resource.annotations, + icons: resource.icons + }); + } + } + return result; + } + + /** + * Get all templates as MCPResourceTemplateInfo array (flattened with server names) + */ + getAllTemplateInfos(): MCPResourceTemplateInfo[] { + const result: MCPResourceTemplateInfo[] = []; + for (const [serverName, serverRes] of this._serverResources) { + for (const template of serverRes.templates) { + result.push({ + uriTemplate: template.uriTemplate, + name: template.name, + title: template.title, + description: template.description, + mimeType: template.mimeType, + serverName, + annotations: template.annotations, + icons: template.icons + }); + } + } + return result; + } + + /** + * Clear resources for a server (e.g., when disconnected) + */ + clearServerResources(serverName: string): void { + this._serverResources.delete(serverName); + // Also clear cached content for this server's resources + for (const [uri, cached] of this._cachedResources) { + if (cached.resource.serverName === serverName) { + this._cachedResources.delete(uri); + } + } + // Clear subscriptions for this server + for (const [uri, sub] of this._subscriptions) { + if (sub.serverName === serverName) { + this._subscriptions.delete(uri); + } + } + console.log(`[MCPResources][${serverName}] Cleared all resources`); + } + + /** + * + * + * Resource Content Caching + * + * + */ + + /** + * Cache resource content after reading + */ + cacheResourceContent(resource: MCPResourceInfo, content: MCPResourceContent[]): void { + // Enforce cache size limit + if (this._cachedResources.size >= MAX_CACHED_RESOURCES) { + // Remove oldest entry + const oldestKey = this._cachedResources.keys().next().value; + if (oldestKey) { + this._cachedResources.delete(oldestKey); + } + } + + this._cachedResources.set(resource.uri, { + resource, + content, + fetchedAt: new Date(), + subscribed: this._subscriptions.has(resource.uri) + }); + console.log(`[MCPResources] Cached content for: ${resource.uri}`); + } + + /** + * Get cached content for a resource + */ + getCachedContent(uri: string): MCPCachedResource | undefined { + const cached = this._cachedResources.get(uri); + if (!cached) return undefined; + + // Check if cache is still valid + const age = Date.now() - cached.fetchedAt.getTime(); + if (age > CACHE_TTL_MS && !cached.subscribed) { + // Cache expired and not subscribed, remove it + this._cachedResources.delete(uri); + return undefined; + } + + return cached; + } + + /** + * Invalidate cached content for a resource (e.g., on update notification) + */ + invalidateCache(uri: string): void { + this._cachedResources.delete(uri); + console.log(`[MCPResources] Invalidated cache for: ${uri}`); + } + + /** + * Clear all cached content + */ + clearCache(): void { + this._cachedResources.clear(); + console.log(`[MCPResources] Cleared all cached content`); + } + + /** + * + * + * Subscriptions + * + * + */ + + /** + * Register a subscription for a resource + */ + addSubscription(uri: string, serverName: string): void { + this._subscriptions.set(uri, { + uri, + serverName, + subscribedAt: new Date() + }); + + // Update cached resource if exists + const cached = this._cachedResources.get(uri); + if (cached) { + this._cachedResources.set(uri, { ...cached, subscribed: true }); + } + + console.log(`[MCPResources] Added subscription: ${uri}`); + } + + /** + * Remove a subscription for a resource + */ + removeSubscription(uri: string): void { + this._subscriptions.delete(uri); + + // Update cached resource if exists + const cached = this._cachedResources.get(uri); + if (cached) { + this._cachedResources.set(uri, { ...cached, subscribed: false }); + } + + console.log(`[MCPResources] Removed subscription: ${uri}`); + } + + /** + * Check if a resource is subscribed + */ + isSubscribed(uri: string): boolean { + return this._subscriptions.has(uri); + } + + /** + * Handle resource update notification + */ + handleResourceUpdate(uri: string): void { + // Invalidate cache so next read gets fresh content + this.invalidateCache(uri); + + // Update subscription last update time + const sub = this._subscriptions.get(uri); + if (sub) { + this._subscriptions.set(uri, { ...sub, lastUpdate: new Date() }); + } + + console.log(`[MCPResources] Resource updated: ${uri}`); + } + + /** + * Handle resources list changed notification + */ + handleResourcesListChanged(serverName: string): void { + // Mark server resources as needing refresh + const existing = this._serverResources.get(serverName); + if (existing) { + this._serverResources.set(serverName, { + ...existing, + lastFetched: undefined // Mark as stale + }); + } + console.log(`[MCPResources][${serverName}] Resources list changed, needs refresh`); + } + + /** + * + * + * Attachments (for chat context) + * + * + */ + + /** + * Add a resource attachment to the current chat context + */ + addAttachment(resource: MCPResourceInfo): MCPResourceAttachment { + const attachment: MCPResourceAttachment = { + id: generateAttachmentId(), + resource, + loading: true + }; + + this._attachments = [...this._attachments, attachment]; + console.log(`[MCPResources] Added attachment: ${resource.uri}`); + + return attachment; + } + + /** + * Update attachment with fetched content + */ + updateAttachmentContent(attachmentId: string, content: MCPResourceContent[]): void { + this._attachments = this._attachments.map((att) => + att.id === attachmentId ? { ...att, content, loading: false, error: undefined } : att + ); + } + + /** + * Update attachment with error + */ + updateAttachmentError(attachmentId: string, error: string): void { + this._attachments = this._attachments.map((att) => + att.id === attachmentId ? { ...att, loading: false, error } : att + ); + } + + /** + * Remove an attachment + */ + removeAttachment(attachmentId: string): void { + this._attachments = this._attachments.filter((att) => att.id !== attachmentId); + console.log(`[MCPResources] Removed attachment: ${attachmentId}`); + } + + /** + * Clear all attachments + */ + clearAttachments(): void { + this._attachments = []; + console.log(`[MCPResources] Cleared all attachments`); + } + + /** + * Get attachment by ID + */ + getAttachment(attachmentId: string): MCPResourceAttachment | undefined { + return this._attachments.find((att) => att.id === attachmentId); + } + + /** + * Check if a resource is already attached + */ + isAttached(uri: string): boolean { + return this._attachments.some((att) => att.resource.uri === uri); + } + + /** + * + * + * Utility Methods + * + * + */ + + /** + * Set global loading state + */ + setLoading(loading: boolean): void { + this._isLoading = loading; + } + + /** + * Find resource info by URI across all servers + */ + findResourceByUri(uri: string): MCPResourceInfo | undefined { + for (const [serverName, serverRes] of this._serverResources) { + const resource = serverRes.resources.find((r) => r.uri === uri); + if (resource) { + return { + uri: resource.uri, + name: resource.name, + title: resource.title, + description: resource.description, + mimeType: resource.mimeType, + serverName, + annotations: resource.annotations, + icons: resource.icons + }; + } + } + return undefined; + } + + /** + * Find server name for a resource URI + */ + findServerForUri(uri: string): string | undefined { + for (const [serverName, serverRes] of this._serverResources) { + if (serverRes.resources.some((r) => r.uri === uri)) { + return serverName; + } + } + return undefined; + } + + /** + * Clear all state (e.g., on full reset) + */ + clear(): void { + this._serverResources.clear(); + this._cachedResources.clear(); + this._subscriptions.clear(); + this._attachments = []; + this._isLoading = false; + console.log(`[MCPResources] Cleared all state`); + } + + /** + * Get resource content as text for chat context + * Formats content for inclusion in LLM prompts + */ + formatAttachmentsForContext(): string { + if (this._attachments.length === 0) return ''; + + const parts: string[] = []; + + for (const attachment of this._attachments) { + if (attachment.error) continue; + if (!attachment.content || attachment.content.length === 0) continue; + + const resourceName = attachment.resource.title || attachment.resource.name; + const serverName = attachment.resource.serverName; + + for (const content of attachment.content) { + if ('text' in content && content.text) { + parts.push(`\n\n--- Resource: ${resourceName} (from ${serverName}) ---\n${content.text}`); + } else if ('blob' in content && content.blob) { + // For binary content, just note it exists + parts.push( + `\n\n--- Resource: ${resourceName} (from ${serverName}) ---\n[Binary content: ${content.mimeType || 'unknown type'}]` + ); + } + } + } + + return parts.join(''); + } +} + +export const mcpResourceStore = new MCPResourceStore(); + +// Export convenience functions +export const mcpResources = () => mcpResourceStore.serverResources; +export const mcpResourceAttachments = () => mcpResourceStore.attachments; +export const mcpResourceAttachmentCount = () => mcpResourceStore.attachmentCount; +export const mcpHasResourceAttachments = () => mcpResourceStore.hasAttachments; +export const mcpTotalResourceCount = () => mcpResourceStore.totalResourceCount; +export const mcpResourcesLoading = () => mcpResourceStore.isLoading;