From 083f22f2b2c2d72553d4433249e702b77400de3d Mon Sep 17 00:00:00 2001 From: SameRainbows Date: Sun, 1 Nov 2026 18:51:10 +0300 Subject: [PATCH 1/4] fix(server): sync resource and template title on metadata updates (#3103) --- .changeset/sync-resource-metadata-title.md | 5 + packages/server/src/server/mcp.ts | 18 +++- .../server/resourceMetadataTitleSync.test.ts | 93 +++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 .changeset/sync-resource-metadata-title.md create mode 100644 packages/server/test/server/resourceMetadataTitleSync.test.ts 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..53a439dbc9 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,14 @@ 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..e01d98eac7 --- /dev/null +++ b/packages/server/test/server/resourceMetadataTitleSync.test.ts @@ -0,0 +1,93 @@ +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 + let 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 + let 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'); + }); +}); From d55d429ce9a672c31c8fab38243b3a0129c419bd Mon Sep 17 00:00:00 2001 From: SameRainbows Date: Sun, 1 Nov 2026 18:52:53 +0300 Subject: [PATCH 2/4] test(server): optional chain indexed access in resourceMetadataTitleSync --- .../server/test/server/resourceMetadataTitleSync.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/server/test/server/resourceMetadataTitleSync.test.ts b/packages/server/test/server/resourceMetadataTitleSync.test.ts index e01d98eac7..92bd0036ae 100644 --- a/packages/server/test/server/resourceMetadataTitleSync.test.ts +++ b/packages/server/test/server/resourceMetadataTitleSync.test.ts @@ -40,8 +40,8 @@ describe('resource and template metadata title synchronization (#3103)', () => { ); // 1. Initial list advertises title - let listResult = (await initializeAndRequest(server, { method: 'resources/list' })) as { resources: Array<{ uri: string; title?: string; mimeType?: string }> }; - expect(listResult.resources[0].title).toBe('Original 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 @@ -71,8 +71,8 @@ describe('resource and template metadata title synchronization (#3103)', () => { ); // 1. Initial templates list advertises title - let listResult = (await initializeAndRequest(server, { method: 'resources/templates/list' })) as { resourceTemplates: Array<{ name: string; title?: string }> }; - expect(listResult.resourceTemplates[0].title).toBe('Template Original'); + 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 From 7b31d1bc42c27e76cd335f5e4f80a272e7a3f59f Mon Sep 17 00:00:00 2001 From: SameRainbows Date: Sun, 1 Nov 2026 21:27:05 +0300 Subject: [PATCH 3/4] style: format with prettier --- packages/server/src/server/mcp.ts | 97 +++++++++---------- .../server/resourceMetadataTitleSync.test.ts | 8 +- 2 files changed, 53 insertions(+), 52 deletions(-) diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 53a439dbc9..f3d772b8fe 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -261,37 +261,34 @@ export class McpServer { // Note: tools are listed in registration (insertion) order, which keeps the ordering // deterministic across requests when the underlying tool set has not changed, as // recommended by the spec. - this.server.setRequestHandler( - 'tools/list', - (): ListToolsResult => ({ - tools: Object.entries(this._registeredTools) - .filter(([, tool]) => tool.enabled) - .map(([name, tool]): Tool => { - const toolDefinition: Tool = { - name, - title: tool.title, - description: tool.description, - inputSchema: tool.inputSchema - ? (convertListedInputSchema(name, tool.inputSchema) as Tool['inputSchema']) - : EMPTY_OBJECT_JSON_SCHEMA, - annotations: tool.annotations, - icons: tool.icons, - execution: tool.execution, - _meta: tool._meta - }; + this.server.setRequestHandler('tools/list', (): ListToolsResult => ({ + tools: Object.entries(this._registeredTools) + .filter(([, tool]) => tool.enabled) + .map(([name, tool]): Tool => { + const toolDefinition: Tool = { + name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema + ? (convertListedInputSchema(name, tool.inputSchema) as Tool['inputSchema']) + : EMPTY_OBJECT_JSON_SCHEMA, + annotations: tool.annotations, + icons: tool.icons, + execution: tool.execution, + _meta: tool._meta + }; + + if (tool.outputSchema) { + // SEP-2106 legacy interop (non-object outputSchema roots wrapped in + // `{type:'object',properties:{result:},required:['result']}` toward + // 2025-era clients) lives in the 2025 wire codec's `encodeResult('tools/list', …)` + // — this handler is era-blind and emits the natural converted schema. + toolDefinition.outputSchema = standardSchemaToJsonSchema(tool.outputSchema, 'output') as Tool['outputSchema']; + } - if (tool.outputSchema) { - // SEP-2106 legacy interop (non-object outputSchema roots wrapped in - // `{type:'object',properties:{result:},required:['result']}` toward - // 2025-era clients) lives in the 2025 wire codec's `encodeResult('tools/list', …)` - // — this handler is era-blind and emits the natural converted schema. - toolDefinition.outputSchema = standardSchemaToJsonSchema(tool.outputSchema, 'output') as Tool['outputSchema']; - } - - return toolDefinition; - }) - }) - ); + return toolDefinition; + }) + })); this.server.setRequestHandler('tools/call', async (request, ctx): Promise => { const tool = this._registeredTools[request.params.name]; @@ -347,11 +344,11 @@ export class McpServer { */ private async validateToolInput< ToolType extends RegisteredTool, - Args extends ToolType['inputSchema'] extends infer InputSchema + Args extends (ToolType['inputSchema'] extends infer InputSchema ? InputSchema extends StandardSchemaWithJSON ? StandardSchemaWithJSON.InferOutput : undefined - : undefined + : undefined) >(tool: ToolType, args: Args, toolName: string): Promise { if ( this._maxToolInputElements !== undefined && @@ -633,23 +630,20 @@ export class McpServer { } }); - this.server.setRequestHandler( - 'prompts/list', - (): ListPromptsResult => ({ - prompts: Object.entries(this._registeredPrompts) - .filter(([, prompt]) => prompt.enabled) - .map(([name, prompt]): Prompt => { - return { - name, - title: prompt.title, - description: prompt.description, - arguments: prompt.argsSchema ? promptArgumentsFromStandardSchema(prompt.argsSchema) : undefined, - icons: prompt.icons, - _meta: prompt._meta - }; - }) - }) - ); + this.server.setRequestHandler('prompts/list', (): ListPromptsResult => ({ + prompts: Object.entries(this._registeredPrompts) + .filter(([, prompt]) => prompt.enabled) + .map(([name, prompt]): Prompt => { + return { + name, + title: prompt.title, + description: prompt.description, + arguments: prompt.argsSchema ? promptArgumentsFromStandardSchema(prompt.argsSchema) : undefined, + icons: prompt.icons, + _meta: prompt._meta + }; + }) + })); this.server.setRequestHandler('prompts/get', async (request, ctx): Promise => { const prompt = this._registeredPrompts[request.params.name]; @@ -833,7 +827,10 @@ export class McpServer { if (updates.metadata.title !== undefined) { registeredResourceTemplate.title = updates.metadata.title; } else if (registeredResourceTemplate.title !== undefined) { - registeredResourceTemplate.metadata = { ...registeredResourceTemplate.metadata, title: registeredResourceTemplate.title }; + registeredResourceTemplate.metadata = { + ...registeredResourceTemplate.metadata, + title: registeredResourceTemplate.title + }; } } if (updates.title !== undefined) { diff --git a/packages/server/test/server/resourceMetadataTitleSync.test.ts b/packages/server/test/server/resourceMetadataTitleSync.test.ts index 92bd0036ae..a84aa9587c 100644 --- a/packages/server/test/server/resourceMetadataTitleSync.test.ts +++ b/packages/server/test/server/resourceMetadataTitleSync.test.ts @@ -40,7 +40,9 @@ describe('resource and template metadata title synchronization (#3103)', () => { ); // 1. Initial list advertises title - const listResult = (await initializeAndRequest(server, { method: 'resources/list' })) as { resources: Array<{ uri: string; title?: string; mimeType?: string }> }; + 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'); @@ -71,7 +73,9 @@ describe('resource and template metadata title synchronization (#3103)', () => { ); // 1. Initial templates list advertises title - const listResult = (await initializeAndRequest(server, { method: 'resources/templates/list' })) as { resourceTemplates: Array<{ name: string; title?: string }> }; + 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'); From d87854dd1e95d575351d8d29fd2c115c4a729c93 Mon Sep 17 00:00:00 2001 From: SameRainbows Date: Sun, 1 Nov 2026 22:01:50 +0300 Subject: [PATCH 4/4] style: align with repo prettier@3.6.2 rules --- packages/server/src/server/mcp.ts | 92 ++++++++++++++++--------------- 1 file changed, 49 insertions(+), 43 deletions(-) diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index f3d772b8fe..300205387f 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -261,34 +261,37 @@ export class McpServer { // Note: tools are listed in registration (insertion) order, which keeps the ordering // deterministic across requests when the underlying tool set has not changed, as // recommended by the spec. - this.server.setRequestHandler('tools/list', (): ListToolsResult => ({ - tools: Object.entries(this._registeredTools) - .filter(([, tool]) => tool.enabled) - .map(([name, tool]): Tool => { - const toolDefinition: Tool = { - name, - title: tool.title, - description: tool.description, - inputSchema: tool.inputSchema - ? (convertListedInputSchema(name, tool.inputSchema) as Tool['inputSchema']) - : EMPTY_OBJECT_JSON_SCHEMA, - annotations: tool.annotations, - icons: tool.icons, - execution: tool.execution, - _meta: tool._meta - }; - - if (tool.outputSchema) { - // SEP-2106 legacy interop (non-object outputSchema roots wrapped in - // `{type:'object',properties:{result:},required:['result']}` toward - // 2025-era clients) lives in the 2025 wire codec's `encodeResult('tools/list', …)` - // — this handler is era-blind and emits the natural converted schema. - toolDefinition.outputSchema = standardSchemaToJsonSchema(tool.outputSchema, 'output') as Tool['outputSchema']; - } + this.server.setRequestHandler( + 'tools/list', + (): ListToolsResult => ({ + tools: Object.entries(this._registeredTools) + .filter(([, tool]) => tool.enabled) + .map(([name, tool]): Tool => { + const toolDefinition: Tool = { + name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema + ? (convertListedInputSchema(name, tool.inputSchema) as Tool['inputSchema']) + : EMPTY_OBJECT_JSON_SCHEMA, + annotations: tool.annotations, + icons: tool.icons, + execution: tool.execution, + _meta: tool._meta + }; - return toolDefinition; - }) - })); + if (tool.outputSchema) { + // SEP-2106 legacy interop (non-object outputSchema roots wrapped in + // `{type:'object',properties:{result:},required:['result']}` toward + // 2025-era clients) lives in the 2025 wire codec's `encodeResult('tools/list', …)` + // — this handler is era-blind and emits the natural converted schema. + toolDefinition.outputSchema = standardSchemaToJsonSchema(tool.outputSchema, 'output') as Tool['outputSchema']; + } + + return toolDefinition; + }) + }) + ); this.server.setRequestHandler('tools/call', async (request, ctx): Promise => { const tool = this._registeredTools[request.params.name]; @@ -344,11 +347,11 @@ export class McpServer { */ private async validateToolInput< ToolType extends RegisteredTool, - Args extends (ToolType['inputSchema'] extends infer InputSchema + Args extends ToolType['inputSchema'] extends infer InputSchema ? InputSchema extends StandardSchemaWithJSON ? StandardSchemaWithJSON.InferOutput : undefined - : undefined) + : undefined >(tool: ToolType, args: Args, toolName: string): Promise { if ( this._maxToolInputElements !== undefined && @@ -630,20 +633,23 @@ export class McpServer { } }); - this.server.setRequestHandler('prompts/list', (): ListPromptsResult => ({ - prompts: Object.entries(this._registeredPrompts) - .filter(([, prompt]) => prompt.enabled) - .map(([name, prompt]): Prompt => { - return { - name, - title: prompt.title, - description: prompt.description, - arguments: prompt.argsSchema ? promptArgumentsFromStandardSchema(prompt.argsSchema) : undefined, - icons: prompt.icons, - _meta: prompt._meta - }; - }) - })); + this.server.setRequestHandler( + 'prompts/list', + (): ListPromptsResult => ({ + prompts: Object.entries(this._registeredPrompts) + .filter(([, prompt]) => prompt.enabled) + .map(([name, prompt]): Prompt => { + return { + name, + title: prompt.title, + description: prompt.description, + arguments: prompt.argsSchema ? promptArgumentsFromStandardSchema(prompt.argsSchema) : undefined, + icons: prompt.icons, + _meta: prompt._meta + }; + }) + }) + ); this.server.setRequestHandler('prompts/get', async (request, ctx): Promise => { const prompt = this._registeredPrompts[request.params.name];