From daa81428201c0f25ecb73e04a6c9a19b863bb988 Mon Sep 17 00:00:00 2001 From: chen yang <84712384+ycwlzx@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:29:34 +0800 Subject: [PATCH 1/2] fix(everything): scope the session resource registry to the owning server registerSessionResource kept its re-registration bookkeeping in a module-level Map keyed by URI only. Every session gets its own McpServer from createServer(), so that map is shared: when one session registered a URI another session had already registered, existingResource.remove() unregistered the first session's resource from its own server, and that session's resources/read then failed with -32602 while the resource also disappeared from its resources/list. Session resources are documented as per-session (docs/features.md, "available only for the lifetime of the session"; docs/how-it-works.md, "served from memory for the life of the session"), and the sibling subscriptions.ts already keys its state by sessionId. A module-level map is the odd one out. Scope the registry to the McpServer that owns the resource with a WeakMap>, so remove()/set() only ever touch resources registered on that same server instance. A WeakMap also lets a closed session's registry be collected. Re-registration within one session still replaces that session's own previous registration, which is what 3e1be88 added the map for. --- src/everything/__tests__/resources.test.ts | 707 +++++++++++---------- src/everything/resources/session.ts | 180 +++--- 2 files changed, 480 insertions(+), 407 deletions(-) diff --git a/src/everything/__tests__/resources.test.ts b/src/everything/__tests__/resources.test.ts index a22b904175..7d3ae2a339 100644 --- a/src/everything/__tests__/resources.test.ts +++ b/src/everything/__tests__/resources.test.ts @@ -1,327 +1,380 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { - textResource, - blobResource, - textResourceUri, - blobResourceUri, - RESOURCE_TYPE_TEXT, - RESOURCE_TYPE_BLOB, - RESOURCE_TYPES, - resourceTypeCompleter, - resourceIdForPromptCompleter, - resourceIdForResourceTemplateCompleter, - registerResourceTemplates, -} from '../resources/templates.js'; -import { - getSessionResourceURI, - registerSessionResource, -} from '../resources/session.js'; -import { registerFileResources } from '../resources/files.js'; -import { - setSubscriptionHandlers, - beginSimulatedResourceUpdates, - stopSimulatedResourceUpdates, -} from '../resources/subscriptions.js'; - -describe('Resource Templates', () => { - describe('Constants', () => { - it('should include both types in RESOURCE_TYPES array', () => { - expect(RESOURCE_TYPES).toContain(RESOURCE_TYPE_TEXT); - expect(RESOURCE_TYPES).toContain(RESOURCE_TYPE_BLOB); - expect(RESOURCE_TYPES).toHaveLength(2); - }); - }); - - describe('textResourceUri', () => { - it('should create URL for text resource', () => { - const uri = textResourceUri(1); - expect(uri.toString()).toBe('demo://resource/dynamic/text/1'); - }); - - it('should handle different resource IDs', () => { - expect(textResourceUri(5).toString()).toBe('demo://resource/dynamic/text/5'); - expect(textResourceUri(100).toString()).toBe('demo://resource/dynamic/text/100'); - }); - }); - - describe('blobResourceUri', () => { - it('should create URL for blob resource', () => { - const uri = blobResourceUri(1); - expect(uri.toString()).toBe('demo://resource/dynamic/blob/1'); - }); - - it('should handle different resource IDs', () => { - expect(blobResourceUri(5).toString()).toBe('demo://resource/dynamic/blob/5'); - expect(blobResourceUri(100).toString()).toBe('demo://resource/dynamic/blob/100'); - }); - }); - - describe('textResource', () => { - it('should create text resource with correct structure', () => { - const uri = textResourceUri(1); - const resource = textResource(uri, 1); - - expect(resource.uri).toBe(uri.toString()); - expect(resource.mimeType).toBe('text/plain'); - expect(resource.text).toContain('Resource 1'); - expect(resource.text).toContain('plaintext'); - }); - - it('should include timestamp in content', () => { - const uri = textResourceUri(2); - const resource = textResource(uri, 2); - - // Timestamp format varies, just check it contains time-related content - expect(resource.text).toMatch(/\d/); - }); - }); - - describe('blobResource', () => { - it('should create blob resource with correct structure', () => { - const uri = blobResourceUri(1); - const resource = blobResource(uri, 1); - - expect(resource.uri).toBe(uri.toString()); - expect(resource.mimeType).toBe('text/plain'); - expect(resource.blob).toBeDefined(); - }); - - it('should create valid base64 encoded content', () => { - const uri = blobResourceUri(3); - const resource = blobResource(uri, 3); - - // Decode and verify content - const decoded = Buffer.from(resource.blob, 'base64').toString(); - expect(decoded).toContain('Resource 3'); - expect(decoded).toContain('base64 blob'); - }); - }); - - describe('resourceTypeCompleter', () => { - it('should be defined as a completable schema', () => { - // The completer is a zod schema wrapped with completable - expect(resourceTypeCompleter).toBeDefined(); - // It should have the zod parse method - expect(typeof (resourceTypeCompleter as any).parse).toBe('function'); - }); - - it('should validate string resource types', () => { - // Test that valid strings pass validation - expect(() => (resourceTypeCompleter as any).parse('Text')).not.toThrow(); - expect(() => (resourceTypeCompleter as any).parse('Blob')).not.toThrow(); - }); - }); - - describe('resourceIdForPromptCompleter', () => { - it('should be defined as a completable schema', () => { - expect(resourceIdForPromptCompleter).toBeDefined(); - expect(typeof (resourceIdForPromptCompleter as any).parse).toBe('function'); - }); - - it('should validate string IDs', () => { - // Test that valid strings pass validation - expect(() => (resourceIdForPromptCompleter as any).parse('1')).not.toThrow(); - expect(() => (resourceIdForPromptCompleter as any).parse('100')).not.toThrow(); - }); - }); - - describe('resourceIdForResourceTemplateCompleter', () => { - it('should validate positive integer IDs', () => { - expect(resourceIdForResourceTemplateCompleter('1')).toEqual(['1']); - expect(resourceIdForResourceTemplateCompleter('50')).toEqual(['50']); - }); - - it('should reject invalid IDs', () => { - expect(resourceIdForResourceTemplateCompleter('0')).toEqual([]); - expect(resourceIdForResourceTemplateCompleter('-5')).toEqual([]); - expect(resourceIdForResourceTemplateCompleter('not-a-number')).toEqual([]); - }); - }); - - describe('registerResourceTemplates', () => { - it('should register text and blob resource templates', () => { - const registeredResources: any[] = []; - - const mockServer = { - registerResource: vi.fn((...args) => { - registeredResources.push(args); - }), - } as unknown as McpServer; - - registerResourceTemplates(mockServer); - - expect(mockServer.registerResource).toHaveBeenCalledTimes(2); - - // Check text resource registration - const textRegistration = registeredResources.find((r) => - r[0].includes('Text') - ); - expect(textRegistration).toBeDefined(); - expect(textRegistration[1]).toBeInstanceOf(ResourceTemplate); - - // Check blob resource registration - const blobRegistration = registeredResources.find((r) => - r[0].includes('Blob') - ); - expect(blobRegistration).toBeDefined(); - }); - }); -}); - -describe('Session Resources', () => { - describe('getSessionResourceURI', () => { - it('should generate correct URI for resource name', () => { - expect(getSessionResourceURI('test')).toBe('demo://resource/session/test'); - }); - - it('should handle various resource names', () => { - expect(getSessionResourceURI('my-file')).toBe('demo://resource/session/my-file'); - expect(getSessionResourceURI('document_123')).toBe( - 'demo://resource/session/document_123' - ); - }); - }); - - describe('registerSessionResource', () => { - it('should register text resource and return resource link', () => { - const registrations: any[] = []; - const mockServer = { - registerResource: vi.fn((...args) => { - registrations.push(args); - }), - } as unknown as McpServer; - - const resource = { - uri: 'demo://resource/session/test-file', - name: 'test-file', - mimeType: 'text/plain', - description: 'A test file', - }; - - const result = registerSessionResource( - mockServer, - resource, - 'text', - 'Hello, World!' - ); - - expect(result.type).toBe('resource_link'); - expect(result.uri).toBe(resource.uri); - expect(result.name).toBe(resource.name); - - expect(mockServer.registerResource).toHaveBeenCalledWith( - 'test-file', - 'demo://resource/session/test-file', - expect.objectContaining({ - mimeType: 'text/plain', - description: 'A test file', - }), - expect.any(Function) - ); - }); - - it('should register blob resource correctly', () => { - const mockServer = { - registerResource: vi.fn(), - } as unknown as McpServer; - - const resource = { - uri: 'demo://resource/session/binary-file', - name: 'binary-file', - mimeType: 'application/octet-stream', - }; - - const blobContent = Buffer.from('binary data').toString('base64'); - const result = registerSessionResource(mockServer, resource, 'blob', blobContent); - - expect(result.type).toBe('resource_link'); - expect(mockServer.registerResource).toHaveBeenCalled(); - }); - - it('should return resource handler that provides correct content', async () => { - let capturedHandler: Function | null = null; - const mockServer = { - registerResource: vi.fn((_name, _uri, _config, handler) => { - capturedHandler = handler; - }), - } as unknown as McpServer; - - const resource = { - uri: 'demo://resource/session/content-test', - name: 'content-test', - mimeType: 'text/plain', - }; - - registerSessionResource(mockServer, resource, 'text', 'Test content here'); - - expect(capturedHandler).not.toBeNull(); - - const handlerResult = await capturedHandler!(new URL(resource.uri)); - expect(handlerResult.contents).toHaveLength(1); - expect(handlerResult.contents[0].text).toBe('Test content here'); - expect(handlerResult.contents[0].mimeType).toBe('text/plain'); - }); - }); -}); - -describe('File Resources', () => { - describe('registerFileResources', () => { - it('should register file resources when docs directory exists', () => { - const mockServer = { - registerResource: vi.fn(), - } as unknown as McpServer; - - registerFileResources(mockServer); - - // The docs folder exists in the everything server and contains files - // so registerResource should have been called - expect(mockServer.registerResource).toHaveBeenCalled(); - }); - }); -}); - -describe('Subscriptions', () => { - describe('setSubscriptionHandlers', () => { - it('should set request handlers on server', () => { - const mockServer = { - server: { - setRequestHandler: vi.fn(), - }, - sendLoggingMessage: vi.fn(), - } as unknown as McpServer; - - setSubscriptionHandlers(mockServer); - - // Should set both subscribe and unsubscribe handlers - expect(mockServer.server.setRequestHandler).toHaveBeenCalledTimes(2); - }); - }); - - describe('simulated resource updates lifecycle', () => { - afterEach(() => { - // Clean up any intervals - stopSimulatedResourceUpdates('lifecycle-test-session'); - }); - - it('should start and stop updates without errors', () => { - const mockServer = { - server: { - notification: vi.fn(), - }, - } as unknown as McpServer; - - // Start updates - should work for both defined and undefined sessionId - beginSimulatedResourceUpdates(mockServer, 'lifecycle-test-session'); - beginSimulatedResourceUpdates(mockServer, undefined); - - // Stop updates - should handle all cases gracefully - stopSimulatedResourceUpdates('lifecycle-test-session'); - stopSimulatedResourceUpdates('non-existent-session'); - stopSimulatedResourceUpdates(undefined); - - // If we got here without throwing, the lifecycle works correctly - expect(true).toBe(true); - }); - }); -}); +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { + textResource, + blobResource, + textResourceUri, + blobResourceUri, + RESOURCE_TYPE_TEXT, + RESOURCE_TYPE_BLOB, + RESOURCE_TYPES, + resourceTypeCompleter, + resourceIdForPromptCompleter, + resourceIdForResourceTemplateCompleter, + registerResourceTemplates, +} from '../resources/templates.js'; +import { + getSessionResourceURI, + registerSessionResource, +} from '../resources/session.js'; +import { registerFileResources } from '../resources/files.js'; +import { + setSubscriptionHandlers, + beginSimulatedResourceUpdates, + stopSimulatedResourceUpdates, +} from '../resources/subscriptions.js'; + +describe('Resource Templates', () => { + describe('Constants', () => { + it('should include both types in RESOURCE_TYPES array', () => { + expect(RESOURCE_TYPES).toContain(RESOURCE_TYPE_TEXT); + expect(RESOURCE_TYPES).toContain(RESOURCE_TYPE_BLOB); + expect(RESOURCE_TYPES).toHaveLength(2); + }); + }); + + describe('textResourceUri', () => { + it('should create URL for text resource', () => { + const uri = textResourceUri(1); + expect(uri.toString()).toBe('demo://resource/dynamic/text/1'); + }); + + it('should handle different resource IDs', () => { + expect(textResourceUri(5).toString()).toBe('demo://resource/dynamic/text/5'); + expect(textResourceUri(100).toString()).toBe('demo://resource/dynamic/text/100'); + }); + }); + + describe('blobResourceUri', () => { + it('should create URL for blob resource', () => { + const uri = blobResourceUri(1); + expect(uri.toString()).toBe('demo://resource/dynamic/blob/1'); + }); + + it('should handle different resource IDs', () => { + expect(blobResourceUri(5).toString()).toBe('demo://resource/dynamic/blob/5'); + expect(blobResourceUri(100).toString()).toBe('demo://resource/dynamic/blob/100'); + }); + }); + + describe('textResource', () => { + it('should create text resource with correct structure', () => { + const uri = textResourceUri(1); + const resource = textResource(uri, 1); + + expect(resource.uri).toBe(uri.toString()); + expect(resource.mimeType).toBe('text/plain'); + expect(resource.text).toContain('Resource 1'); + expect(resource.text).toContain('plaintext'); + }); + + it('should include timestamp in content', () => { + const uri = textResourceUri(2); + const resource = textResource(uri, 2); + + // Timestamp format varies, just check it contains time-related content + expect(resource.text).toMatch(/\d/); + }); + }); + + describe('blobResource', () => { + it('should create blob resource with correct structure', () => { + const uri = blobResourceUri(1); + const resource = blobResource(uri, 1); + + expect(resource.uri).toBe(uri.toString()); + expect(resource.mimeType).toBe('text/plain'); + expect(resource.blob).toBeDefined(); + }); + + it('should create valid base64 encoded content', () => { + const uri = blobResourceUri(3); + const resource = blobResource(uri, 3); + + // Decode and verify content + const decoded = Buffer.from(resource.blob, 'base64').toString(); + expect(decoded).toContain('Resource 3'); + expect(decoded).toContain('base64 blob'); + }); + }); + + describe('resourceTypeCompleter', () => { + it('should be defined as a completable schema', () => { + // The completer is a zod schema wrapped with completable + expect(resourceTypeCompleter).toBeDefined(); + // It should have the zod parse method + expect(typeof (resourceTypeCompleter as any).parse).toBe('function'); + }); + + it('should validate string resource types', () => { + // Test that valid strings pass validation + expect(() => (resourceTypeCompleter as any).parse('Text')).not.toThrow(); + expect(() => (resourceTypeCompleter as any).parse('Blob')).not.toThrow(); + }); + }); + + describe('resourceIdForPromptCompleter', () => { + it('should be defined as a completable schema', () => { + expect(resourceIdForPromptCompleter).toBeDefined(); + expect(typeof (resourceIdForPromptCompleter as any).parse).toBe('function'); + }); + + it('should validate string IDs', () => { + // Test that valid strings pass validation + expect(() => (resourceIdForPromptCompleter as any).parse('1')).not.toThrow(); + expect(() => (resourceIdForPromptCompleter as any).parse('100')).not.toThrow(); + }); + }); + + describe('resourceIdForResourceTemplateCompleter', () => { + it('should validate positive integer IDs', () => { + expect(resourceIdForResourceTemplateCompleter('1')).toEqual(['1']); + expect(resourceIdForResourceTemplateCompleter('50')).toEqual(['50']); + }); + + it('should reject invalid IDs', () => { + expect(resourceIdForResourceTemplateCompleter('0')).toEqual([]); + expect(resourceIdForResourceTemplateCompleter('-5')).toEqual([]); + expect(resourceIdForResourceTemplateCompleter('not-a-number')).toEqual([]); + }); + }); + + describe('registerResourceTemplates', () => { + it('should register text and blob resource templates', () => { + const registeredResources: any[] = []; + + const mockServer = { + registerResource: vi.fn((...args) => { + registeredResources.push(args); + }), + } as unknown as McpServer; + + registerResourceTemplates(mockServer); + + expect(mockServer.registerResource).toHaveBeenCalledTimes(2); + + // Check text resource registration + const textRegistration = registeredResources.find((r) => + r[0].includes('Text') + ); + expect(textRegistration).toBeDefined(); + expect(textRegistration[1]).toBeInstanceOf(ResourceTemplate); + + // Check blob resource registration + const blobRegistration = registeredResources.find((r) => + r[0].includes('Blob') + ); + expect(blobRegistration).toBeDefined(); + }); + }); +}); + +describe('Session Resources', () => { + describe('getSessionResourceURI', () => { + it('should generate correct URI for resource name', () => { + expect(getSessionResourceURI('test')).toBe('demo://resource/session/test'); + }); + + it('should handle various resource names', () => { + expect(getSessionResourceURI('my-file')).toBe('demo://resource/session/my-file'); + expect(getSessionResourceURI('document_123')).toBe( + 'demo://resource/session/document_123' + ); + }); + }); + + describe('registerSessionResource', () => { + it('should register text resource and return resource link', () => { + const registrations: any[] = []; + const mockServer = { + registerResource: vi.fn((...args) => { + registrations.push(args); + }), + } as unknown as McpServer; + + const resource = { + uri: 'demo://resource/session/test-file', + name: 'test-file', + mimeType: 'text/plain', + description: 'A test file', + }; + + const result = registerSessionResource( + mockServer, + resource, + 'text', + 'Hello, World!' + ); + + expect(result.type).toBe('resource_link'); + expect(result.uri).toBe(resource.uri); + expect(result.name).toBe(resource.name); + + expect(mockServer.registerResource).toHaveBeenCalledWith( + 'test-file', + 'demo://resource/session/test-file', + expect.objectContaining({ + mimeType: 'text/plain', + description: 'A test file', + }), + expect.any(Function) + ); + }); + + it('should register blob resource correctly', () => { + const mockServer = { + registerResource: vi.fn(), + } as unknown as McpServer; + + const resource = { + uri: 'demo://resource/session/binary-file', + name: 'binary-file', + mimeType: 'application/octet-stream', + }; + + const blobContent = Buffer.from('binary data').toString('base64'); + const result = registerSessionResource(mockServer, resource, 'blob', blobContent); + + expect(result.type).toBe('resource_link'); + expect(mockServer.registerResource).toHaveBeenCalled(); + }); + + it('should return resource handler that provides correct content', async () => { + let capturedHandler: Function | null = null; + const mockServer = { + registerResource: vi.fn((_name, _uri, _config, handler) => { + capturedHandler = handler; + }), + } as unknown as McpServer; + + const resource = { + uri: 'demo://resource/session/content-test', + name: 'content-test', + mimeType: 'text/plain', + }; + + registerSessionResource(mockServer, resource, 'text', 'Test content here'); + + expect(capturedHandler).not.toBeNull(); + + const handlerResult = await capturedHandler!(new URL(resource.uri)); + expect(handlerResult.contents).toHaveLength(1); + expect(handlerResult.contents[0].text).toBe('Test content here'); + expect(handlerResult.contents[0].mimeType).toBe('text/plain'); + }); + + it('should not remove a resource another session registered under the same URI', () => { + const removedFromA: string[] = []; + const removedFromB: string[] = []; + + const sessionA = { + registerResource: vi.fn((_name, uri) => { + return { remove: () => removedFromA.push(String(uri)) }; + }), + } as unknown as McpServer; + + const sessionB = { + registerResource: vi.fn((_name, uri) => { + return { remove: () => removedFromB.push(String(uri)) }; + }), + } as unknown as McpServer; + + const resource = { + uri: 'demo://resource/session/shared.gz', + name: 'shared.gz', + mimeType: 'application/gzip', + }; + + registerSessionResource(sessionA, resource, 'blob', 'from-a'); + registerSessionResource(sessionB, resource, 'blob', 'from-b'); + + // Each session must keep serving its own resource. + expect(removedFromA).toEqual([]); + expect(removedFromB).toEqual([]); + }); + + it('should still remove its own previous registration for the same URI', () => { + const removed: string[] = []; + + const server = { + registerResource: vi.fn((_name, uri) => { + return { remove: () => removed.push(String(uri)) }; + }), + } as unknown as McpServer; + + const resource = { + uri: 'demo://resource/session/retry.gz', + name: 'retry.gz', + mimeType: 'application/gzip', + }; + + // A tool called twice with the same output name must not fail with + // "Resource already registered"; the earlier registration is replaced. + registerSessionResource(server, resource, 'blob', 'first'); + registerSessionResource(server, resource, 'blob', 'second'); + + expect(removed).toEqual(['demo://resource/session/retry.gz']); + }); + }); +}); + +describe('File Resources', () => { + describe('registerFileResources', () => { + it('should register file resources when docs directory exists', () => { + const mockServer = { + registerResource: vi.fn(), + } as unknown as McpServer; + + registerFileResources(mockServer); + + // The docs folder exists in the everything server and contains files + // so registerResource should have been called + expect(mockServer.registerResource).toHaveBeenCalled(); + }); + }); +}); + +describe('Subscriptions', () => { + describe('setSubscriptionHandlers', () => { + it('should set request handlers on server', () => { + const mockServer = { + server: { + setRequestHandler: vi.fn(), + }, + sendLoggingMessage: vi.fn(), + } as unknown as McpServer; + + setSubscriptionHandlers(mockServer); + + // Should set both subscribe and unsubscribe handlers + expect(mockServer.server.setRequestHandler).toHaveBeenCalledTimes(2); + }); + }); + + describe('simulated resource updates lifecycle', () => { + afterEach(() => { + // Clean up any intervals + stopSimulatedResourceUpdates('lifecycle-test-session'); + }); + + it('should start and stop updates without errors', () => { + const mockServer = { + server: { + notification: vi.fn(), + }, + } as unknown as McpServer; + + // Start updates - should work for both defined and undefined sessionId + beginSimulatedResourceUpdates(mockServer, 'lifecycle-test-session'); + beginSimulatedResourceUpdates(mockServer, undefined); + + // Stop updates - should handle all cases gracefully + stopSimulatedResourceUpdates('lifecycle-test-session'); + stopSimulatedResourceUpdates('non-existent-session'); + stopSimulatedResourceUpdates(undefined); + + // If we got here without throwing, the lifecycle works correctly + expect(true).toBe(true); + }); + }); +}); diff --git a/src/everything/resources/session.ts b/src/everything/resources/session.ts index 10e0db33c1..a5b6211034 100644 --- a/src/everything/resources/session.ts +++ b/src/everything/resources/session.ts @@ -1,80 +1,100 @@ -import { McpServer, RegisteredResource } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { Resource, ResourceLink } from "@modelcontextprotocol/sdk/types.js"; - -/** - * Tracks registered session resources by URI to allow updating/removing on re-registration. - * This prevents "Resource already registered" errors when a tool creates a resource - * with the same URI multiple times during a session. - */ -const registeredResources = new Map(); - -/** - * Generates a session-scoped resource URI string based on the provided resource name. - * - * @param {string} name - The name of the resource to create a URI for. - * @returns {string} The formatted session resource URI. - */ -export const getSessionResourceURI = (name: string): string => { - return `demo://resource/session/${name}`; -}; - -/** - * Registers a session-scoped resource with the provided server and returns a resource link. - * - * The registered resource is available during the life of the session only; it is not otherwise persisted. - * - * @param {McpServer} server - The server instance responsible for handling the resource registration. - * @param {Resource} resource - The resource object containing metadata such as URI, name, description, and mimeType. - * @param {"text"|"blob"} type - * @param payload - * @returns {ResourceLink} An object representing the resource link, with associated metadata. - */ -export const registerSessionResource = ( - server: McpServer, - resource: Resource, - type: "text" | "blob", - payload: string -): ResourceLink => { - // Destructure resource - const { uri, name, mimeType, description, title, annotations, icons, _meta } = - resource; - - // Prepare the resource content to return - // See https://modelcontextprotocol.io/specification/2025-11-25/server/resources#resource-contents - const resourceContent = - type === "text" - ? { - uri: uri.toString(), - mimeType, - text: payload, - } - : { - uri: uri.toString(), - mimeType, - blob: payload, - }; - - // Check if a resource with this URI is already registered and remove it - const existingResource = registeredResources.get(uri); - if (existingResource) { - existingResource.remove(); - registeredResources.delete(uri); - } - - // Register file resource - const registeredResource = server.registerResource( - name, - uri, - { mimeType, description, title, annotations, icons, _meta }, - async () => { - return { - contents: [resourceContent], - }; - } - ); - - // Track the registered resource for potential future removal - registeredResources.set(uri, registeredResource); - - return { type: "resource_link", ...resource }; -}; +import { McpServer, RegisteredResource } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Resource, ResourceLink } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Tracks registered session resources per server, by URI, to allow updating/removing on + * re-registration. This prevents "Resource already registered" errors when a tool creates a + * resource with the same URI multiple times during a session. + * + * The registry is scoped to the `McpServer` that owns the resource rather than being module + * level: every session gets its own server (see `server/index.ts`), so a module-level map lets + * one session's re-registration remove the resource another session registered under the same + * URI. A `WeakMap` also lets a closed session's registry be collected. + */ +const registeredResources = new WeakMap>(); + +/** + * Returns the resource registry owned by `server`, creating it on first use. + */ +const sessionResourcesFor = (server: McpServer): Map => { + let resources = registeredResources.get(server); + + if (!resources) { + resources = new Map(); + registeredResources.set(server, resources); + } + + return resources; +}; + +/** + * Generates a session-scoped resource URI string based on the provided resource name. + * + * @param {string} name - The name of the resource to create a URI for. + * @returns {string} The formatted session resource URI. + */ +export const getSessionResourceURI = (name: string): string => { + return `demo://resource/session/${name}`; +}; + +/** + * Registers a session-scoped resource with the provided server and returns a resource link. + * + * The registered resource is available during the life of the session only; it is not otherwise persisted. + * + * @param {McpServer} server - The server instance responsible for handling the resource registration. + * @param {Resource} resource - The resource object containing metadata such as URI, name, description, and mimeType. + * @param {"text"|"blob"} type + * @param payload + * @returns {ResourceLink} An object representing the resource link, with associated metadata. + */ +export const registerSessionResource = ( + server: McpServer, + resource: Resource, + type: "text" | "blob", + payload: string +): ResourceLink => { + // Destructure resource + const { uri, name, mimeType, description, title, annotations, icons, _meta } = + resource; + + // Prepare the resource content to return + // See https://modelcontextprotocol.io/specification/2025-11-25/server/resources#resource-contents + const resourceContent = + type === "text" + ? { + uri: uri.toString(), + mimeType, + text: payload, + } + : { + uri: uri.toString(), + mimeType, + blob: payload, + }; + + // Check if this server already registered a resource with this URI and remove that one + const sessionResources = sessionResourcesFor(server); + const existingResource = sessionResources.get(uri); + if (existingResource) { + existingResource.remove(); + sessionResources.delete(uri); + } + + // Register file resource + const registeredResource = server.registerResource( + name, + uri, + { mimeType, description, title, annotations, icons, _meta }, + async () => { + return { + contents: [resourceContent], + }; + } + ); + + // Track the registered resource for potential future removal + sessionResources.set(uri, registeredResource); + + return { type: "resource_link", ...resource }; +}; From 5494131d2373c0f3a41546a0ba87c8de43c293b2 Mon Sep 17 00:00:00 2001 From: chen yang <84712384+ycwlzx@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:30:55 +0800 Subject: [PATCH 2/2] chore(everything): drop CRLF line endings from the diff The previous commit pushed working-tree bytes, which carried CRLF because of a local core.autocrlf setting. GitHub therefore rendered both files as fully rewritten instead of the actual change. Content is identical apart from line endings. --- src/everything/__tests__/resources.test.ts | 760 ++++++++++----------- src/everything/resources/session.ts | 200 +++--- 2 files changed, 480 insertions(+), 480 deletions(-) diff --git a/src/everything/__tests__/resources.test.ts b/src/everything/__tests__/resources.test.ts index 7d3ae2a339..edb57387e2 100644 --- a/src/everything/__tests__/resources.test.ts +++ b/src/everything/__tests__/resources.test.ts @@ -1,380 +1,380 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { - textResource, - blobResource, - textResourceUri, - blobResourceUri, - RESOURCE_TYPE_TEXT, - RESOURCE_TYPE_BLOB, - RESOURCE_TYPES, - resourceTypeCompleter, - resourceIdForPromptCompleter, - resourceIdForResourceTemplateCompleter, - registerResourceTemplates, -} from '../resources/templates.js'; -import { - getSessionResourceURI, - registerSessionResource, -} from '../resources/session.js'; -import { registerFileResources } from '../resources/files.js'; -import { - setSubscriptionHandlers, - beginSimulatedResourceUpdates, - stopSimulatedResourceUpdates, -} from '../resources/subscriptions.js'; - -describe('Resource Templates', () => { - describe('Constants', () => { - it('should include both types in RESOURCE_TYPES array', () => { - expect(RESOURCE_TYPES).toContain(RESOURCE_TYPE_TEXT); - expect(RESOURCE_TYPES).toContain(RESOURCE_TYPE_BLOB); - expect(RESOURCE_TYPES).toHaveLength(2); - }); - }); - - describe('textResourceUri', () => { - it('should create URL for text resource', () => { - const uri = textResourceUri(1); - expect(uri.toString()).toBe('demo://resource/dynamic/text/1'); - }); - - it('should handle different resource IDs', () => { - expect(textResourceUri(5).toString()).toBe('demo://resource/dynamic/text/5'); - expect(textResourceUri(100).toString()).toBe('demo://resource/dynamic/text/100'); - }); - }); - - describe('blobResourceUri', () => { - it('should create URL for blob resource', () => { - const uri = blobResourceUri(1); - expect(uri.toString()).toBe('demo://resource/dynamic/blob/1'); - }); - - it('should handle different resource IDs', () => { - expect(blobResourceUri(5).toString()).toBe('demo://resource/dynamic/blob/5'); - expect(blobResourceUri(100).toString()).toBe('demo://resource/dynamic/blob/100'); - }); - }); - - describe('textResource', () => { - it('should create text resource with correct structure', () => { - const uri = textResourceUri(1); - const resource = textResource(uri, 1); - - expect(resource.uri).toBe(uri.toString()); - expect(resource.mimeType).toBe('text/plain'); - expect(resource.text).toContain('Resource 1'); - expect(resource.text).toContain('plaintext'); - }); - - it('should include timestamp in content', () => { - const uri = textResourceUri(2); - const resource = textResource(uri, 2); - - // Timestamp format varies, just check it contains time-related content - expect(resource.text).toMatch(/\d/); - }); - }); - - describe('blobResource', () => { - it('should create blob resource with correct structure', () => { - const uri = blobResourceUri(1); - const resource = blobResource(uri, 1); - - expect(resource.uri).toBe(uri.toString()); - expect(resource.mimeType).toBe('text/plain'); - expect(resource.blob).toBeDefined(); - }); - - it('should create valid base64 encoded content', () => { - const uri = blobResourceUri(3); - const resource = blobResource(uri, 3); - - // Decode and verify content - const decoded = Buffer.from(resource.blob, 'base64').toString(); - expect(decoded).toContain('Resource 3'); - expect(decoded).toContain('base64 blob'); - }); - }); - - describe('resourceTypeCompleter', () => { - it('should be defined as a completable schema', () => { - // The completer is a zod schema wrapped with completable - expect(resourceTypeCompleter).toBeDefined(); - // It should have the zod parse method - expect(typeof (resourceTypeCompleter as any).parse).toBe('function'); - }); - - it('should validate string resource types', () => { - // Test that valid strings pass validation - expect(() => (resourceTypeCompleter as any).parse('Text')).not.toThrow(); - expect(() => (resourceTypeCompleter as any).parse('Blob')).not.toThrow(); - }); - }); - - describe('resourceIdForPromptCompleter', () => { - it('should be defined as a completable schema', () => { - expect(resourceIdForPromptCompleter).toBeDefined(); - expect(typeof (resourceIdForPromptCompleter as any).parse).toBe('function'); - }); - - it('should validate string IDs', () => { - // Test that valid strings pass validation - expect(() => (resourceIdForPromptCompleter as any).parse('1')).not.toThrow(); - expect(() => (resourceIdForPromptCompleter as any).parse('100')).not.toThrow(); - }); - }); - - describe('resourceIdForResourceTemplateCompleter', () => { - it('should validate positive integer IDs', () => { - expect(resourceIdForResourceTemplateCompleter('1')).toEqual(['1']); - expect(resourceIdForResourceTemplateCompleter('50')).toEqual(['50']); - }); - - it('should reject invalid IDs', () => { - expect(resourceIdForResourceTemplateCompleter('0')).toEqual([]); - expect(resourceIdForResourceTemplateCompleter('-5')).toEqual([]); - expect(resourceIdForResourceTemplateCompleter('not-a-number')).toEqual([]); - }); - }); - - describe('registerResourceTemplates', () => { - it('should register text and blob resource templates', () => { - const registeredResources: any[] = []; - - const mockServer = { - registerResource: vi.fn((...args) => { - registeredResources.push(args); - }), - } as unknown as McpServer; - - registerResourceTemplates(mockServer); - - expect(mockServer.registerResource).toHaveBeenCalledTimes(2); - - // Check text resource registration - const textRegistration = registeredResources.find((r) => - r[0].includes('Text') - ); - expect(textRegistration).toBeDefined(); - expect(textRegistration[1]).toBeInstanceOf(ResourceTemplate); - - // Check blob resource registration - const blobRegistration = registeredResources.find((r) => - r[0].includes('Blob') - ); - expect(blobRegistration).toBeDefined(); - }); - }); -}); - -describe('Session Resources', () => { - describe('getSessionResourceURI', () => { - it('should generate correct URI for resource name', () => { - expect(getSessionResourceURI('test')).toBe('demo://resource/session/test'); - }); - - it('should handle various resource names', () => { - expect(getSessionResourceURI('my-file')).toBe('demo://resource/session/my-file'); - expect(getSessionResourceURI('document_123')).toBe( - 'demo://resource/session/document_123' - ); - }); - }); - - describe('registerSessionResource', () => { - it('should register text resource and return resource link', () => { - const registrations: any[] = []; - const mockServer = { - registerResource: vi.fn((...args) => { - registrations.push(args); - }), - } as unknown as McpServer; - - const resource = { - uri: 'demo://resource/session/test-file', - name: 'test-file', - mimeType: 'text/plain', - description: 'A test file', - }; - - const result = registerSessionResource( - mockServer, - resource, - 'text', - 'Hello, World!' - ); - - expect(result.type).toBe('resource_link'); - expect(result.uri).toBe(resource.uri); - expect(result.name).toBe(resource.name); - - expect(mockServer.registerResource).toHaveBeenCalledWith( - 'test-file', - 'demo://resource/session/test-file', - expect.objectContaining({ - mimeType: 'text/plain', - description: 'A test file', - }), - expect.any(Function) - ); - }); - - it('should register blob resource correctly', () => { - const mockServer = { - registerResource: vi.fn(), - } as unknown as McpServer; - - const resource = { - uri: 'demo://resource/session/binary-file', - name: 'binary-file', - mimeType: 'application/octet-stream', - }; - - const blobContent = Buffer.from('binary data').toString('base64'); - const result = registerSessionResource(mockServer, resource, 'blob', blobContent); - - expect(result.type).toBe('resource_link'); - expect(mockServer.registerResource).toHaveBeenCalled(); - }); - - it('should return resource handler that provides correct content', async () => { - let capturedHandler: Function | null = null; - const mockServer = { - registerResource: vi.fn((_name, _uri, _config, handler) => { - capturedHandler = handler; - }), - } as unknown as McpServer; - - const resource = { - uri: 'demo://resource/session/content-test', - name: 'content-test', - mimeType: 'text/plain', - }; - - registerSessionResource(mockServer, resource, 'text', 'Test content here'); - - expect(capturedHandler).not.toBeNull(); - - const handlerResult = await capturedHandler!(new URL(resource.uri)); - expect(handlerResult.contents).toHaveLength(1); - expect(handlerResult.contents[0].text).toBe('Test content here'); - expect(handlerResult.contents[0].mimeType).toBe('text/plain'); - }); - - it('should not remove a resource another session registered under the same URI', () => { - const removedFromA: string[] = []; - const removedFromB: string[] = []; - - const sessionA = { - registerResource: vi.fn((_name, uri) => { - return { remove: () => removedFromA.push(String(uri)) }; - }), - } as unknown as McpServer; - - const sessionB = { - registerResource: vi.fn((_name, uri) => { - return { remove: () => removedFromB.push(String(uri)) }; - }), - } as unknown as McpServer; - - const resource = { - uri: 'demo://resource/session/shared.gz', - name: 'shared.gz', - mimeType: 'application/gzip', - }; - - registerSessionResource(sessionA, resource, 'blob', 'from-a'); - registerSessionResource(sessionB, resource, 'blob', 'from-b'); - - // Each session must keep serving its own resource. - expect(removedFromA).toEqual([]); - expect(removedFromB).toEqual([]); - }); - - it('should still remove its own previous registration for the same URI', () => { - const removed: string[] = []; - - const server = { - registerResource: vi.fn((_name, uri) => { - return { remove: () => removed.push(String(uri)) }; - }), - } as unknown as McpServer; - - const resource = { - uri: 'demo://resource/session/retry.gz', - name: 'retry.gz', - mimeType: 'application/gzip', - }; - - // A tool called twice with the same output name must not fail with - // "Resource already registered"; the earlier registration is replaced. - registerSessionResource(server, resource, 'blob', 'first'); - registerSessionResource(server, resource, 'blob', 'second'); - - expect(removed).toEqual(['demo://resource/session/retry.gz']); - }); - }); -}); - -describe('File Resources', () => { - describe('registerFileResources', () => { - it('should register file resources when docs directory exists', () => { - const mockServer = { - registerResource: vi.fn(), - } as unknown as McpServer; - - registerFileResources(mockServer); - - // The docs folder exists in the everything server and contains files - // so registerResource should have been called - expect(mockServer.registerResource).toHaveBeenCalled(); - }); - }); -}); - -describe('Subscriptions', () => { - describe('setSubscriptionHandlers', () => { - it('should set request handlers on server', () => { - const mockServer = { - server: { - setRequestHandler: vi.fn(), - }, - sendLoggingMessage: vi.fn(), - } as unknown as McpServer; - - setSubscriptionHandlers(mockServer); - - // Should set both subscribe and unsubscribe handlers - expect(mockServer.server.setRequestHandler).toHaveBeenCalledTimes(2); - }); - }); - - describe('simulated resource updates lifecycle', () => { - afterEach(() => { - // Clean up any intervals - stopSimulatedResourceUpdates('lifecycle-test-session'); - }); - - it('should start and stop updates without errors', () => { - const mockServer = { - server: { - notification: vi.fn(), - }, - } as unknown as McpServer; - - // Start updates - should work for both defined and undefined sessionId - beginSimulatedResourceUpdates(mockServer, 'lifecycle-test-session'); - beginSimulatedResourceUpdates(mockServer, undefined); - - // Stop updates - should handle all cases gracefully - stopSimulatedResourceUpdates('lifecycle-test-session'); - stopSimulatedResourceUpdates('non-existent-session'); - stopSimulatedResourceUpdates(undefined); - - // If we got here without throwing, the lifecycle works correctly - expect(true).toBe(true); - }); - }); -}); +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { + textResource, + blobResource, + textResourceUri, + blobResourceUri, + RESOURCE_TYPE_TEXT, + RESOURCE_TYPE_BLOB, + RESOURCE_TYPES, + resourceTypeCompleter, + resourceIdForPromptCompleter, + resourceIdForResourceTemplateCompleter, + registerResourceTemplates, +} from '../resources/templates.js'; +import { + getSessionResourceURI, + registerSessionResource, +} from '../resources/session.js'; +import { registerFileResources } from '../resources/files.js'; +import { + setSubscriptionHandlers, + beginSimulatedResourceUpdates, + stopSimulatedResourceUpdates, +} from '../resources/subscriptions.js'; + +describe('Resource Templates', () => { + describe('Constants', () => { + it('should include both types in RESOURCE_TYPES array', () => { + expect(RESOURCE_TYPES).toContain(RESOURCE_TYPE_TEXT); + expect(RESOURCE_TYPES).toContain(RESOURCE_TYPE_BLOB); + expect(RESOURCE_TYPES).toHaveLength(2); + }); + }); + + describe('textResourceUri', () => { + it('should create URL for text resource', () => { + const uri = textResourceUri(1); + expect(uri.toString()).toBe('demo://resource/dynamic/text/1'); + }); + + it('should handle different resource IDs', () => { + expect(textResourceUri(5).toString()).toBe('demo://resource/dynamic/text/5'); + expect(textResourceUri(100).toString()).toBe('demo://resource/dynamic/text/100'); + }); + }); + + describe('blobResourceUri', () => { + it('should create URL for blob resource', () => { + const uri = blobResourceUri(1); + expect(uri.toString()).toBe('demo://resource/dynamic/blob/1'); + }); + + it('should handle different resource IDs', () => { + expect(blobResourceUri(5).toString()).toBe('demo://resource/dynamic/blob/5'); + expect(blobResourceUri(100).toString()).toBe('demo://resource/dynamic/blob/100'); + }); + }); + + describe('textResource', () => { + it('should create text resource with correct structure', () => { + const uri = textResourceUri(1); + const resource = textResource(uri, 1); + + expect(resource.uri).toBe(uri.toString()); + expect(resource.mimeType).toBe('text/plain'); + expect(resource.text).toContain('Resource 1'); + expect(resource.text).toContain('plaintext'); + }); + + it('should include timestamp in content', () => { + const uri = textResourceUri(2); + const resource = textResource(uri, 2); + + // Timestamp format varies, just check it contains time-related content + expect(resource.text).toMatch(/\d/); + }); + }); + + describe('blobResource', () => { + it('should create blob resource with correct structure', () => { + const uri = blobResourceUri(1); + const resource = blobResource(uri, 1); + + expect(resource.uri).toBe(uri.toString()); + expect(resource.mimeType).toBe('text/plain'); + expect(resource.blob).toBeDefined(); + }); + + it('should create valid base64 encoded content', () => { + const uri = blobResourceUri(3); + const resource = blobResource(uri, 3); + + // Decode and verify content + const decoded = Buffer.from(resource.blob, 'base64').toString(); + expect(decoded).toContain('Resource 3'); + expect(decoded).toContain('base64 blob'); + }); + }); + + describe('resourceTypeCompleter', () => { + it('should be defined as a completable schema', () => { + // The completer is a zod schema wrapped with completable + expect(resourceTypeCompleter).toBeDefined(); + // It should have the zod parse method + expect(typeof (resourceTypeCompleter as any).parse).toBe('function'); + }); + + it('should validate string resource types', () => { + // Test that valid strings pass validation + expect(() => (resourceTypeCompleter as any).parse('Text')).not.toThrow(); + expect(() => (resourceTypeCompleter as any).parse('Blob')).not.toThrow(); + }); + }); + + describe('resourceIdForPromptCompleter', () => { + it('should be defined as a completable schema', () => { + expect(resourceIdForPromptCompleter).toBeDefined(); + expect(typeof (resourceIdForPromptCompleter as any).parse).toBe('function'); + }); + + it('should validate string IDs', () => { + // Test that valid strings pass validation + expect(() => (resourceIdForPromptCompleter as any).parse('1')).not.toThrow(); + expect(() => (resourceIdForPromptCompleter as any).parse('100')).not.toThrow(); + }); + }); + + describe('resourceIdForResourceTemplateCompleter', () => { + it('should validate positive integer IDs', () => { + expect(resourceIdForResourceTemplateCompleter('1')).toEqual(['1']); + expect(resourceIdForResourceTemplateCompleter('50')).toEqual(['50']); + }); + + it('should reject invalid IDs', () => { + expect(resourceIdForResourceTemplateCompleter('0')).toEqual([]); + expect(resourceIdForResourceTemplateCompleter('-5')).toEqual([]); + expect(resourceIdForResourceTemplateCompleter('not-a-number')).toEqual([]); + }); + }); + + describe('registerResourceTemplates', () => { + it('should register text and blob resource templates', () => { + const registeredResources: any[] = []; + + const mockServer = { + registerResource: vi.fn((...args) => { + registeredResources.push(args); + }), + } as unknown as McpServer; + + registerResourceTemplates(mockServer); + + expect(mockServer.registerResource).toHaveBeenCalledTimes(2); + + // Check text resource registration + const textRegistration = registeredResources.find((r) => + r[0].includes('Text') + ); + expect(textRegistration).toBeDefined(); + expect(textRegistration[1]).toBeInstanceOf(ResourceTemplate); + + // Check blob resource registration + const blobRegistration = registeredResources.find((r) => + r[0].includes('Blob') + ); + expect(blobRegistration).toBeDefined(); + }); + }); +}); + +describe('Session Resources', () => { + describe('getSessionResourceURI', () => { + it('should generate correct URI for resource name', () => { + expect(getSessionResourceURI('test')).toBe('demo://resource/session/test'); + }); + + it('should handle various resource names', () => { + expect(getSessionResourceURI('my-file')).toBe('demo://resource/session/my-file'); + expect(getSessionResourceURI('document_123')).toBe( + 'demo://resource/session/document_123' + ); + }); + }); + + describe('registerSessionResource', () => { + it('should register text resource and return resource link', () => { + const registrations: any[] = []; + const mockServer = { + registerResource: vi.fn((...args) => { + registrations.push(args); + }), + } as unknown as McpServer; + + const resource = { + uri: 'demo://resource/session/test-file', + name: 'test-file', + mimeType: 'text/plain', + description: 'A test file', + }; + + const result = registerSessionResource( + mockServer, + resource, + 'text', + 'Hello, World!' + ); + + expect(result.type).toBe('resource_link'); + expect(result.uri).toBe(resource.uri); + expect(result.name).toBe(resource.name); + + expect(mockServer.registerResource).toHaveBeenCalledWith( + 'test-file', + 'demo://resource/session/test-file', + expect.objectContaining({ + mimeType: 'text/plain', + description: 'A test file', + }), + expect.any(Function) + ); + }); + + it('should register blob resource correctly', () => { + const mockServer = { + registerResource: vi.fn(), + } as unknown as McpServer; + + const resource = { + uri: 'demo://resource/session/binary-file', + name: 'binary-file', + mimeType: 'application/octet-stream', + }; + + const blobContent = Buffer.from('binary data').toString('base64'); + const result = registerSessionResource(mockServer, resource, 'blob', blobContent); + + expect(result.type).toBe('resource_link'); + expect(mockServer.registerResource).toHaveBeenCalled(); + }); + + it('should return resource handler that provides correct content', async () => { + let capturedHandler: Function | null = null; + const mockServer = { + registerResource: vi.fn((_name, _uri, _config, handler) => { + capturedHandler = handler; + }), + } as unknown as McpServer; + + const resource = { + uri: 'demo://resource/session/content-test', + name: 'content-test', + mimeType: 'text/plain', + }; + + registerSessionResource(mockServer, resource, 'text', 'Test content here'); + + expect(capturedHandler).not.toBeNull(); + + const handlerResult = await capturedHandler!(new URL(resource.uri)); + expect(handlerResult.contents).toHaveLength(1); + expect(handlerResult.contents[0].text).toBe('Test content here'); + expect(handlerResult.contents[0].mimeType).toBe('text/plain'); + }); + + it('should not remove a resource another session registered under the same URI', () => { + const removedFromA: string[] = []; + const removedFromB: string[] = []; + + const sessionA = { + registerResource: vi.fn((_name, uri) => { + return { remove: () => removedFromA.push(String(uri)) }; + }), + } as unknown as McpServer; + + const sessionB = { + registerResource: vi.fn((_name, uri) => { + return { remove: () => removedFromB.push(String(uri)) }; + }), + } as unknown as McpServer; + + const resource = { + uri: 'demo://resource/session/shared.gz', + name: 'shared.gz', + mimeType: 'application/gzip', + }; + + registerSessionResource(sessionA, resource, 'blob', 'from-a'); + registerSessionResource(sessionB, resource, 'blob', 'from-b'); + + // Each session must keep serving its own resource. + expect(removedFromA).toEqual([]); + expect(removedFromB).toEqual([]); + }); + + it('should still remove its own previous registration for the same URI', () => { + const removed: string[] = []; + + const server = { + registerResource: vi.fn((_name, uri) => { + return { remove: () => removed.push(String(uri)) }; + }), + } as unknown as McpServer; + + const resource = { + uri: 'demo://resource/session/retry.gz', + name: 'retry.gz', + mimeType: 'application/gzip', + }; + + // A tool called twice with the same output name must not fail with + // "Resource already registered"; the earlier registration is replaced. + registerSessionResource(server, resource, 'blob', 'first'); + registerSessionResource(server, resource, 'blob', 'second'); + + expect(removed).toEqual(['demo://resource/session/retry.gz']); + }); + }); +}); + +describe('File Resources', () => { + describe('registerFileResources', () => { + it('should register file resources when docs directory exists', () => { + const mockServer = { + registerResource: vi.fn(), + } as unknown as McpServer; + + registerFileResources(mockServer); + + // The docs folder exists in the everything server and contains files + // so registerResource should have been called + expect(mockServer.registerResource).toHaveBeenCalled(); + }); + }); +}); + +describe('Subscriptions', () => { + describe('setSubscriptionHandlers', () => { + it('should set request handlers on server', () => { + const mockServer = { + server: { + setRequestHandler: vi.fn(), + }, + sendLoggingMessage: vi.fn(), + } as unknown as McpServer; + + setSubscriptionHandlers(mockServer); + + // Should set both subscribe and unsubscribe handlers + expect(mockServer.server.setRequestHandler).toHaveBeenCalledTimes(2); + }); + }); + + describe('simulated resource updates lifecycle', () => { + afterEach(() => { + // Clean up any intervals + stopSimulatedResourceUpdates('lifecycle-test-session'); + }); + + it('should start and stop updates without errors', () => { + const mockServer = { + server: { + notification: vi.fn(), + }, + } as unknown as McpServer; + + // Start updates - should work for both defined and undefined sessionId + beginSimulatedResourceUpdates(mockServer, 'lifecycle-test-session'); + beginSimulatedResourceUpdates(mockServer, undefined); + + // Stop updates - should handle all cases gracefully + stopSimulatedResourceUpdates('lifecycle-test-session'); + stopSimulatedResourceUpdates('non-existent-session'); + stopSimulatedResourceUpdates(undefined); + + // If we got here without throwing, the lifecycle works correctly + expect(true).toBe(true); + }); + }); +}); diff --git a/src/everything/resources/session.ts b/src/everything/resources/session.ts index a5b6211034..5059a900dc 100644 --- a/src/everything/resources/session.ts +++ b/src/everything/resources/session.ts @@ -1,100 +1,100 @@ -import { McpServer, RegisteredResource } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { Resource, ResourceLink } from "@modelcontextprotocol/sdk/types.js"; - -/** - * Tracks registered session resources per server, by URI, to allow updating/removing on - * re-registration. This prevents "Resource already registered" errors when a tool creates a - * resource with the same URI multiple times during a session. - * - * The registry is scoped to the `McpServer` that owns the resource rather than being module - * level: every session gets its own server (see `server/index.ts`), so a module-level map lets - * one session's re-registration remove the resource another session registered under the same - * URI. A `WeakMap` also lets a closed session's registry be collected. - */ -const registeredResources = new WeakMap>(); - -/** - * Returns the resource registry owned by `server`, creating it on first use. - */ -const sessionResourcesFor = (server: McpServer): Map => { - let resources = registeredResources.get(server); - - if (!resources) { - resources = new Map(); - registeredResources.set(server, resources); - } - - return resources; -}; - -/** - * Generates a session-scoped resource URI string based on the provided resource name. - * - * @param {string} name - The name of the resource to create a URI for. - * @returns {string} The formatted session resource URI. - */ -export const getSessionResourceURI = (name: string): string => { - return `demo://resource/session/${name}`; -}; - -/** - * Registers a session-scoped resource with the provided server and returns a resource link. - * - * The registered resource is available during the life of the session only; it is not otherwise persisted. - * - * @param {McpServer} server - The server instance responsible for handling the resource registration. - * @param {Resource} resource - The resource object containing metadata such as URI, name, description, and mimeType. - * @param {"text"|"blob"} type - * @param payload - * @returns {ResourceLink} An object representing the resource link, with associated metadata. - */ -export const registerSessionResource = ( - server: McpServer, - resource: Resource, - type: "text" | "blob", - payload: string -): ResourceLink => { - // Destructure resource - const { uri, name, mimeType, description, title, annotations, icons, _meta } = - resource; - - // Prepare the resource content to return - // See https://modelcontextprotocol.io/specification/2025-11-25/server/resources#resource-contents - const resourceContent = - type === "text" - ? { - uri: uri.toString(), - mimeType, - text: payload, - } - : { - uri: uri.toString(), - mimeType, - blob: payload, - }; - - // Check if this server already registered a resource with this URI and remove that one - const sessionResources = sessionResourcesFor(server); - const existingResource = sessionResources.get(uri); - if (existingResource) { - existingResource.remove(); - sessionResources.delete(uri); - } - - // Register file resource - const registeredResource = server.registerResource( - name, - uri, - { mimeType, description, title, annotations, icons, _meta }, - async () => { - return { - contents: [resourceContent], - }; - } - ); - - // Track the registered resource for potential future removal - sessionResources.set(uri, registeredResource); - - return { type: "resource_link", ...resource }; -}; +import { McpServer, RegisteredResource } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Resource, ResourceLink } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Tracks registered session resources per server, by URI, to allow updating/removing on + * re-registration. This prevents "Resource already registered" errors when a tool creates a + * resource with the same URI multiple times during a session. + * + * The registry is scoped to the `McpServer` that owns the resource rather than being module + * level: every session gets its own server (see `server/index.ts`), so a module-level map lets + * one session's re-registration remove the resource another session registered under the same + * URI. A `WeakMap` also lets a closed session's registry be collected. + */ +const registeredResources = new WeakMap>(); + +/** + * Returns the resource registry owned by `server`, creating it on first use. + */ +const sessionResourcesFor = (server: McpServer): Map => { + let resources = registeredResources.get(server); + + if (!resources) { + resources = new Map(); + registeredResources.set(server, resources); + } + + return resources; +}; + +/** + * Generates a session-scoped resource URI string based on the provided resource name. + * + * @param {string} name - The name of the resource to create a URI for. + * @returns {string} The formatted session resource URI. + */ +export const getSessionResourceURI = (name: string): string => { + return `demo://resource/session/${name}`; +}; + +/** + * Registers a session-scoped resource with the provided server and returns a resource link. + * + * The registered resource is available during the life of the session only; it is not otherwise persisted. + * + * @param {McpServer} server - The server instance responsible for handling the resource registration. + * @param {Resource} resource - The resource object containing metadata such as URI, name, description, and mimeType. + * @param {"text"|"blob"} type + * @param payload + * @returns {ResourceLink} An object representing the resource link, with associated metadata. + */ +export const registerSessionResource = ( + server: McpServer, + resource: Resource, + type: "text" | "blob", + payload: string +): ResourceLink => { + // Destructure resource + const { uri, name, mimeType, description, title, annotations, icons, _meta } = + resource; + + // Prepare the resource content to return + // See https://modelcontextprotocol.io/specification/2025-11-25/server/resources#resource-contents + const resourceContent = + type === "text" + ? { + uri: uri.toString(), + mimeType, + text: payload, + } + : { + uri: uri.toString(), + mimeType, + blob: payload, + }; + + // Check if this server already registered a resource with this URI and remove that one + const sessionResources = sessionResourcesFor(server); + const existingResource = sessionResources.get(uri); + if (existingResource) { + existingResource.remove(); + sessionResources.delete(uri); + } + + // Register file resource + const registeredResource = server.registerResource( + name, + uri, + { mimeType, description, title, annotations, icons, _meta }, + async () => { + return { + contents: [resourceContent], + }; + } + ); + + // Track the registered resource for potential future removal + sessionResources.set(uri, registeredResource); + + return { type: "resource_link", ...resource }; +};