diff --git a/.changeset/sync-resource-metadata-title.md b/.changeset/sync-resource-metadata-title.md new file mode 100644 index 0000000000..662e780e5d --- /dev/null +++ b/.changeset/sync-resource-metadata-title.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Keep `title` and `metadata.title` synchronized on `registeredResource.update` and `registeredResourceTemplate.update` when updating metadata alone. If new metadata specifies a `title`, it is synchronized to the top-level property; if new metadata omits `title`, any existing top-level title is preserved in metadata rather than being dropped from resource listings. diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index bfb3b798b0..300205387f 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -780,7 +780,14 @@ export class McpServer { if (updates.uri) this._registeredResources[updates.uri] = registeredResource; } if (updates.name !== undefined) registeredResource.name = updates.name; - if (updates.metadata !== undefined) registeredResource.metadata = updates.metadata; + if (updates.metadata !== undefined) { + registeredResource.metadata = updates.metadata; + if (updates.metadata.title !== undefined) { + registeredResource.title = updates.metadata.title; + } else if (registeredResource.title !== undefined) { + registeredResource.metadata = { ...registeredResource.metadata, title: registeredResource.title }; + } + } if (updates.title !== undefined) { registeredResource.title = updates.title; registeredResource.metadata = { ...registeredResource.metadata, title: updates.title }; @@ -821,7 +828,17 @@ export class McpServer { if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate; } if (updates.template !== undefined) registeredResourceTemplate.resourceTemplate = updates.template; - if (updates.metadata !== undefined) registeredResourceTemplate.metadata = updates.metadata; + if (updates.metadata !== undefined) { + registeredResourceTemplate.metadata = updates.metadata; + if (updates.metadata.title !== undefined) { + registeredResourceTemplate.title = updates.metadata.title; + } else if (registeredResourceTemplate.title !== undefined) { + registeredResourceTemplate.metadata = { + ...registeredResourceTemplate.metadata, + title: registeredResourceTemplate.title + }; + } + } if (updates.title !== undefined) { registeredResourceTemplate.title = updates.title; registeredResourceTemplate.metadata = { ...registeredResourceTemplate.metadata, title: updates.title }; diff --git a/packages/server/test/server/resourceMetadataTitleSync.test.ts b/packages/server/test/server/resourceMetadataTitleSync.test.ts new file mode 100644 index 0000000000..a84aa9587c --- /dev/null +++ b/packages/server/test/server/resourceMetadataTitleSync.test.ts @@ -0,0 +1,97 @@ +import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it, vi } from 'vitest'; +import { McpServer, ResourceTemplate } from '../../src/index'; + +async function initializeAndRequest(server: McpServer, request: { method: string; params?: unknown }): Promise> { + const [client, srv] = InMemoryTransport.createLinkedPair(); + await server.connect(srv); + await client.start(); + + const responses: JSONRPCMessage[] = []; + client.onmessage = m => responses.push(m); + + await client.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'client', version: '1.0.0' } + } + } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', method: 'notifications/initialized' } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', id: 2, ...request } as JSONRPCMessage); + + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true)); + const response = responses.find(r => 'id' in r && r.id === 2) as { result?: Record }; + return response.result ?? {}; +} + +describe('resource and template metadata title synchronization (#3103)', () => { + it('syncs title and metadata.title on registered resource metadata updates', async () => { + const server = new McpServer({ name: 'test-server', version: '1.0.0' }); + const resource = server.registerResource( + 'docs', + 'file:///docs', + { title: 'Original Title', mimeType: 'text/markdown' }, + async () => ({ contents: [{ uri: 'file:///docs', text: 'docs' }] }) + ); + + // 1. Initial list advertises title + const listResult = (await initializeAndRequest(server, { method: 'resources/list' })) as { + resources: Array<{ uri: string; title?: string; mimeType?: string }>; + }; + expect(listResult.resources[0]?.title).toBe('Original Title'); + expect(resource.title).toBe('Original Title'); + + // 2. Metadata update without title preserves existing title + resource.update({ metadata: { mimeType: 'text/plain' } }); + expect(resource.title).toBe('Original Title'); + expect(resource.metadata?.title).toBe('Original Title'); + expect(resource.metadata?.mimeType).toBe('text/plain'); + + // 3. Metadata update with a new title syncs to resource.title + resource.update({ metadata: { title: 'Updated via Metadata', mimeType: 'application/json' } }); + expect(resource.title).toBe('Updated via Metadata'); + expect(resource.metadata?.title).toBe('Updated via Metadata'); + + // 4. Explicit title update updates both + resource.update({ title: 'Direct Title' }); + expect(resource.title).toBe('Direct Title'); + expect(resource.metadata?.title).toBe('Direct Title'); + }); + + it('syncs title and metadata.title on registered resource template metadata updates', async () => { + const server = new McpServer({ name: 'test-server', version: '1.0.0' }); + const template = server.registerResource( + 'items', + new ResourceTemplate('items://{id}', { list: undefined }), + { title: 'Template Original', mimeType: 'text/plain' }, + async () => ({ contents: [] }) + ); + + // 1. Initial templates list advertises title + const listResult = (await initializeAndRequest(server, { method: 'resources/templates/list' })) as { + resourceTemplates: Array<{ name: string; title?: string }>; + }; + expect(listResult.resourceTemplates[0]?.title).toBe('Template Original'); + expect(template.title).toBe('Template Original'); + + // 2. Metadata update without title preserves existing title + template.update({ metadata: { mimeType: 'application/octet-stream' } }); + expect(template.title).toBe('Template Original'); + expect(template.metadata?.title).toBe('Template Original'); + + // 3. Metadata update with a new title syncs to template.title + template.update({ metadata: { title: 'Template via Metadata' } }); + expect(template.title).toBe('Template via Metadata'); + expect(template.metadata?.title).toBe('Template via Metadata'); + + // 4. Explicit title update updates both + template.update({ title: 'Template Direct' }); + expect(template.title).toBe('Template Direct'); + expect(template.metadata?.title).toBe('Template Direct'); + }); +});