From 142c8610a7bc5e76f0f5516f938d26c5a4edee87 Mon Sep 17 00:00:00 2001 From: chaeyn Date: Mon, 12 Oct 2026 11:29:43 +0900 Subject: [PATCH] fix: reflect resource title updates in list responses --- .changeset/resource-title-updates.md | 5 + packages/server/src/server/mcp.ts | 10 +- .../test/server/resourceTitleUpdate.test.ts | 98 +++++++++++++++++++ 3 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 .changeset/resource-title-updates.md create mode 100644 test/integration/test/server/resourceTitleUpdate.test.ts diff --git a/.changeset/resource-title-updates.md b/.changeset/resource-title-updates.md new file mode 100644 index 0000000000..a751d80c38 --- /dev/null +++ b/.changeset/resource-title-updates.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Reflect resource and resource template `update({ title })` changes in list responses. Preserve other metadata and individual resource titles when a template supplies list entries. diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 57b7c132eb..bfb3b798b0 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -780,8 +780,11 @@ export class McpServer { if (updates.uri) this._registeredResources[updates.uri] = registeredResource; } if (updates.name !== undefined) registeredResource.name = updates.name; - if (updates.title !== undefined) registeredResource.title = updates.title; if (updates.metadata !== undefined) registeredResource.metadata = updates.metadata; + if (updates.title !== undefined) { + registeredResource.title = updates.title; + registeredResource.metadata = { ...registeredResource.metadata, title: updates.title }; + } if (updates.callback !== undefined) registeredResource.readCallback = updates.callback; if (updates.scopeChallenge !== undefined) { registeredResource.scopeChallenge = updates.scopeChallenge === null ? undefined : updates.scopeChallenge; @@ -817,9 +820,12 @@ export class McpServer { delete this._registeredResourceTemplates[name]; if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate; } - if (updates.title !== undefined) registeredResourceTemplate.title = updates.title; if (updates.template !== undefined) registeredResourceTemplate.resourceTemplate = updates.template; if (updates.metadata !== undefined) registeredResourceTemplate.metadata = updates.metadata; + if (updates.title !== undefined) { + registeredResourceTemplate.title = updates.title; + registeredResourceTemplate.metadata = { ...registeredResourceTemplate.metadata, title: updates.title }; + } if (updates.callback !== undefined) registeredResourceTemplate.readCallback = updates.callback; if (updates.scopeChallenge !== undefined) { registeredResourceTemplate.scopeChallenge = updates.scopeChallenge === null ? undefined : updates.scopeChallenge; diff --git a/test/integration/test/server/resourceTitleUpdate.test.ts b/test/integration/test/server/resourceTitleUpdate.test.ts new file mode 100644 index 0000000000..d1b4e711f4 --- /dev/null +++ b/test/integration/test/server/resourceTitleUpdate.test.ts @@ -0,0 +1,98 @@ +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport } from '@modelcontextprotocol/core-internal'; +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; + +describe('resource title updates', () => { + let server: McpServer; + let client: Client; + + beforeEach(() => { + server = new McpServer({ name: 'test-server', version: '1' }); + client = new Client({ name: 'test-client', version: '1' }); + }); + + afterEach(async () => { + await client.close(); + await server.close(); + }); + + async function connect() { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + } + + async function list(kind: 'static' | 'template') { + if (kind === 'static') { + const result = await client.listResources(undefined, { cacheMode: 'bypass' }); + return result.resources; + } + const result = await client.listResourceTemplates(undefined, { cacheMode: 'bypass' }); + return result.resourceTemplates; + } + + for (const kind of ['static', 'template'] as const) { + for (const initialTitle of [undefined, 'Old title']) { + test(`${kind}: advertises an updated title when the initial title is ${initialTitle}`, async () => { + const target = kind === 'static' ? 'docs://one' : new ResourceTemplate('docs://{id}', { list: undefined }); + const metadata = { title: initialTitle, description: 'Keep this description', mimeType: 'text/plain' }; + const read = async (uri: URL) => ({ contents: [{ uri: uri.href, text: 'document' }] }); + const resource = + typeof target === 'string' + ? server.registerResource('docs', target, metadata, read) + : server.registerResource('docs', target, metadata, read); + await connect(); + + resource.update({ title: 'New title' }); + + const entries = await list(kind); + expect(entries).toEqual([expect.objectContaining({ ...metadata, title: 'New title' })]); + }); + } + + test(`${kind}: keeps an explicit title update when replacing other metadata`, async () => { + const read = async (uri: URL) => ({ contents: [{ uri: uri.href, text: 'document' }] }); + const resource = + kind === 'static' + ? server.registerResource('docs', 'docs://one', { title: 'Old title' }, read) + : server.registerResource( + 'docs', + new ResourceTemplate('docs://{id}', { list: undefined }), + { title: 'Old title' }, + read + ); + await connect(); + + resource.update({ title: 'New title', metadata: { description: 'New description' } }); + + const entries = await list(kind); + expect(entries).toEqual([expect.objectContaining({ title: 'New title', description: 'New description' })]); + }); + } + + test('updates inherited template titles while preserving individual resource titles', async () => { + const resource = server.registerResource( + 'docs', + new ResourceTemplate('docs://{id}', { + list: async () => ({ + resources: [ + { uri: 'docs://one', name: 'one' }, + { uri: 'docs://two', name: 'two', title: 'Individual title' } + ] + }) + }), + { title: 'Old title', mimeType: 'text/plain' }, + async uri => ({ contents: [{ uri: uri.href, text: 'document' }] }) + ); + await connect(); + + resource.update({ title: 'New title' }); + + const { resources } = await client.listResources(undefined, { cacheMode: 'bypass' }); + expect(resources).toEqual([ + { uri: 'docs://one', name: 'one', title: 'New title', mimeType: 'text/plain' }, + { uri: 'docs://two', name: 'two', title: 'Individual title', mimeType: 'text/plain' } + ]); + }); +});