feat: MCP Resources Svelte Store
This commit is contained in:
parent
192c920d73
commit
dc2076a77c
|
|
@ -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<SvelteMap<string, MCPServerResources>>(new SvelteMap());
|
||||
private _cachedResources = $state<SvelteMap<string, MCPCachedResource>>(new SvelteMap());
|
||||
private _subscriptions = $state<SvelteMap<string, MCPResourceSubscription>>(new SvelteMap());
|
||||
private _attachments = $state<MCPResourceAttachment[]>([]);
|
||||
private _isLoading = $state(false);
|
||||
|
||||
get serverResources(): Map<string, MCPServerResources> {
|
||||
return this._serverResources;
|
||||
}
|
||||
|
||||
get cachedResources(): Map<string, MCPCachedResource> {
|
||||
return this._cachedResources;
|
||||
}
|
||||
|
||||
get subscriptions(): Map<string, MCPResourceSubscription> {
|
||||
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;
|
||||
Loading…
Reference in New Issue