Skip to main content
Glama
resourceManager.ts8.94 kB
import { randomUUID } from "node:crypto"; import { promises as fs, constants as fsConstants } from "node:fs"; import os from "node:os"; import path from "node:path"; import type { Server } from "@modelcontextprotocol/sdk/server/index.js"; export interface ResourceLink { uri: string; name: string; description?: string; mimeType?: string; size?: number; } interface FileResource extends ResourceLink { id: string; filePath: string; expiresAt: number; timeout?: NodeJS.Timeout; } const DEFAULT_TTL_MS = 3 * 60 * 1000; const fileResources = new Map<Server, Map<string, FileResource>>(); let linkingEnabled = false; let ttlMs = DEFAULT_TTL_MS; type ResourceUriBuilder = (args: { server: Server; id: string; filename: string }) => string | undefined; let resourceUriBuilder: ResourceUriBuilder | undefined; type SessionIdResolver = (server: Server) => string | undefined; let resolveSessionId: SessionIdResolver | undefined; let resourceBaseDir: string | null = null; let warnedFallback = false; const EXT_MIME_MAP: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".pdf": "application/pdf", ".txt": "text/plain", ".json": "application/json", ".js": "application/javascript", ".ts": "application/typescript", }; export function configureResourceManager({ enabled, ttlSeconds, buildResourceUri, getSessionId, }: { enabled: boolean; ttlSeconds?: number; buildResourceUri?: ResourceUriBuilder; getSessionId?: SessionIdResolver; }) { linkingEnabled = enabled; ttlMs = Math.max(1, Math.floor((ttlSeconds ?? DEFAULT_TTL_MS / 1000) * 1000)); resourceUriBuilder = buildResourceUri; resolveSessionId = getSessionId; cleanupExpired(); } export function getSessionIdForServer(server?: Server): string | undefined { if (!server) return undefined; return resolveSessionId?.(server); } async function cleanupExpired() { const now = Date.now(); for (const [server, resourceMap] of fileResources) { let removed = false; const dirsToCheck = new Set<string>(); for (const [uri, resource] of resourceMap) { if (resource.expiresAt <= now) { if (resource.timeout) { clearTimeout(resource.timeout); } resourceMap.delete(uri); dirsToCheck.add(path.dirname(resource.filePath)); try { await fs.unlink(resource.filePath); } catch { // ignore } removed = true; } } for (const dir of dirsToCheck) { await removeDirIfEmpty(dir); } if (removed && server.sendResourceListChanged) { try { await server.sendResourceListChanged(); } catch (error) { console.warn("Failed to send resource list changed notification:", error); } } } } function getResourceStore(server: Server): Map<string, FileResource> { let store = fileResources.get(server); if (!store) { store = new Map(); fileResources.set(server, store); } return store; } export function clearResourcesForServer(server: Server) { const store = fileResources.get(server); if (!store) return; const dirsToCheck = new Set<string>(); for (const [, resource] of store) { if (resource.timeout) { clearTimeout(resource.timeout); } try { fs.unlink(resource.filePath).catch(() => {}); } catch { // ignore } dirsToCheck.add(path.dirname(resource.filePath)); } fileResources.delete(server); Promise.all([...dirsToCheck].map((dir) => removeDirIfEmpty(dir))).catch(() => {}); } async function ensureResourceBaseDir(): Promise<string> { if (resourceBaseDir) return resourceBaseDir; const preferred = path.join(path.sep, "data"); const fallback = path.join(os.tmpdir(), "mcp-resources"); try { await fs.mkdir(preferred, { recursive: true }); await fs.access(preferred, fsConstants.W_OK); resourceBaseDir = preferred; return resourceBaseDir; } catch { // fall back to tmpdir } if (!warnedFallback) { console.warn(`Resource base ${preferred} not writable; falling back to ${fallback}`); warnedFallback = true; } await fs.mkdir(fallback, { recursive: true }); resourceBaseDir = fallback; return resourceBaseDir; } /** * Register a file-backed resource and optionally notify clients that the list changed. */ export async function registerFileResource({ filePath, name, description, mimeType, server, }: { filePath: string; name?: string; description?: string; mimeType?: string; server?: Server; }): Promise<ResourceLink | undefined> { if (!linkingEnabled || !server) { return undefined; } const sessionId = resolveSessionId?.(server); if (!sessionId) { return undefined; } const absolutePath = path.resolve(filePath); const stats = await fs.stat(absolutePath); const id = randomUUID(); const filename = name ?? path.basename(absolutePath); const uri = resourceUriBuilder?.({ server, id, filename: path.basename(filename) }) ?? `mcp-resource://${id}`; const resolvedMime = mimeType ?? EXT_MIME_MAP[path.extname(filename).toLowerCase()]; const baseDir = await ensureResourceBaseDir(); const sessionDir = path.join(baseDir, sessionId); await fs.mkdir(sessionDir, { recursive: true }); const ext = path.extname(filename) || ""; const storagePath = path.join(sessionDir, `${id}${ext}`); await fs.copyFile(absolutePath, storagePath); const expiresAt = Date.now() + ttlMs; const resource: FileResource = { id, uri, name: filename, description, mimeType: resolvedMime, size: stats.size, filePath: storagePath, expiresAt, }; const store = getResourceStore(server); const existing = Array.from(store.values()).find((r) => r.uri === uri); if (existing?.timeout) { clearTimeout(existing.timeout); } resource.timeout = setTimeout(async () => { store.delete(resource.id); try { await fs.unlink(resource.filePath); } catch { // ignore } await removeDirIfEmpty(path.dirname(resource.filePath)); if (server.sendResourceListChanged) { try { await server.sendResourceListChanged(); } catch (error) { console.warn("Failed to send resource list changed notification:", error); } } }, ttlMs); store.set(resource.id, resource); // Notify clients that the list changed, if the server supports it. if (server.sendResourceListChanged) { try { await server.sendResourceListChanged(); } catch (error) { // Swallow notification errors to avoid breaking the tool response path. console.warn("Failed to send resource list changed notification:", error); } } return { uri: resource.uri, name: resource.name, description: resource.description, mimeType: resource.mimeType, size: resource.size, }; } export async function listFileResources(server: Server): Promise<ResourceLink[]> { await cleanupExpired(); const store = fileResources.get(server); if (!store) return []; return Array.from(store.values()).map(({ uri, name, description, mimeType, size }) => ({ uri, name, description, mimeType, size, })); } export async function readFileResource( uri: string, server: Server, ): Promise<{ resource: ResourceLink; text?: string; blob?: string; } | null> { await cleanupExpired(); const store = fileResources.get(server); if (!store) return null; const resource = Array.from(store.values()).find((r) => { if (r.uri === uri) return true; const lower = uri.toLowerCase(); if (lower.startsWith("mcp-resource://")) { const id = uri.replace("mcp-resource://", ""); return r.id === id; } try { const url = new URL(uri); const parts = url.pathname.split("/").filter(Boolean); const idPart = parts.length >= 2 ? parts[parts.length - 2] : undefined; return idPart === r.id; } catch { return false; } }); if (!resource) return null; const data = await fs.readFile(resource.filePath); const isText = resource.mimeType?.startsWith("text/") || resource.mimeType === "application/json" || resource.mimeType === "application/javascript" || resource.mimeType === "application/typescript"; return { resource, ...(isText ? { text: data.toString("utf8") } : { blob: data.toString("base64") }), }; } export async function getFileResourceById(server: Server, id: string): Promise<FileResource | null> { await cleanupExpired(); const store = fileResources.get(server); if (!store) return null; return store.get(id) ?? null; } export async function removeDirIfEmpty(dir: string) { try { const entries = await fs.readdir(dir); if (entries.length === 0) { await fs.rmdir(dir); console.log(`Removed empty resource directory: ${dir}`); } } catch { // ignore } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aakashH242/mcp-playwright'

If you have feedback or need assistance with the MCP directory API, please join our Discord server