From a26b93605de856437981ccbcaf3b29d790a72788 Mon Sep 17 00:00:00 2001 From: Benoit P Date: Wed, 28 Oct 2026 08:10:42 -0700 Subject: [PATCH 1/2] fix(server): keep an advertised resource title across a metadata-only update resources/list, resources/templates/list and template-supplied list entries spread the registered metadata, and update({ metadata }) replaces that carrier wholesale, so a metadata-only update dropped the title that registration or a later update({ title }) had put there. Both read paths now derive the field: metadata wins when it carries a title, the registered title is the fallback. Refs #3103. --- .changeset/resource-title-metadata-only.md | 5 ++ packages/server/src/server/mcp.ts | 12 ++- .../server/test/server/resourceTitle.test.ts | 89 +++++++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 .changeset/resource-title-metadata-only.md create mode 100644 packages/server/test/server/resourceTitle.test.ts diff --git a/.changeset/resource-title-metadata-only.md b/.changeset/resource-title-metadata-only.md new file mode 100644 index 0000000000..b1752c331c --- /dev/null +++ b/.changeset/resource-title-metadata-only.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +A resource or resource template registered with a `title` keeps advertising it in `resources/list` and `resources/templates/list` after `update({ metadata })`, which replaces the metadata carrier wholesale. Both list paths now derive the field from the registered title when the new metadata does not carry one; metadata that does carry a `title` still wins. diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index bfb3b798b0..e6eee3e311 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -539,7 +539,11 @@ export class McpServer { .map(([uri, resource]) => ({ uri, name: resource.name, - ...resource.metadata + ...resource.metadata, + // `title` is advertised from metadata, but the registration form also stores it + // beside metadata, and a metadata-only update replaces the carrier wholesale. + // Deriving it here keeps the advertised title independent of how it was last written. + title: resource.metadata?.title ?? resource.title })); const templateResources: Resource[] = []; @@ -552,6 +556,9 @@ export class McpServer { for (const resource of result.resources) { templateResources.push({ ...template.metadata, + // Same derivation as `resources/list`: a template registered with a title keeps + // advertising it for its listed instances after a metadata-only update. + title: template.metadata?.title ?? template.title, // the defined resource metadata should override the template metadata if present ...resource }); @@ -565,7 +572,8 @@ export class McpServer { const resourceTemplates = Object.entries(this._registeredResourceTemplates).map(([name, template]) => ({ name, uriTemplate: template.resourceTemplate.uriTemplate.toString(), - ...template.metadata + ...template.metadata, + title: template.metadata?.title ?? template.title })); return { resourceTemplates }; diff --git a/packages/server/test/server/resourceTitle.test.ts b/packages/server/test/server/resourceTitle.test.ts new file mode 100644 index 0000000000..b64d19c771 --- /dev/null +++ b/packages/server/test/server/resourceTitle.test.ts @@ -0,0 +1,89 @@ +/** + * A registered resource or template keeps advertising its title after a + * metadata-only update. + * + * `title` reaches the wire through `resources/list` and `resources/templates/list`, + * which spread the registered metadata. Registration stores the title in two places + * (the handle and the metadata carrier), but `update({ metadata })` replaces the + * carrier wholesale, so a metadata-only update used to drop the advertised title + * even though the handle still held it. Both read paths now derive the field, with + * metadata winning when it carries one and the handle value as the fallback. + */ +import { describe, expect, it } from 'vitest'; + +import { invoke } from '../../src/server/invoke'; +import { McpServer, ResourceTemplate } from '../../src/server/mcp'; + +const LEGACY = { classification: { era: 'legacy' as const } }; + +const listOf = async (server: McpServer, method: string, key: string): Promise>> => { + const response = await invoke(server, { jsonrpc: '2.0', id: 1, method, params: {} }, LEGACY); + const body = (await response.json()) as { result: Record>> }; + return body.result[key]!; +}; + +describe('resource title survives a metadata-only update', () => { + it('resources/list keeps the title after update({ metadata }) and honours a later update({ title })', async () => { + const server = new McpServer({ name: 'titles', version: '0' }); + const handle = server.registerResource('docs', 'mem://docs', { title: 'Docs', mimeType: 'text/markdown' }, async () => ({ + contents: [] + })); + + const [registered] = await listOf(server, 'resources/list', 'resources'); + expect(registered).toMatchObject({ uri: 'mem://docs', name: 'docs', title: 'Docs' }); + + handle.update({ metadata: { mimeType: 'text/markdown', description: 'docs' } }); + const [afterMetadataUpdate] = await listOf(server, 'resources/list', 'resources'); + expect(afterMetadataUpdate).toMatchObject({ uri: 'mem://docs', name: 'docs', title: 'Docs' }); + expect(afterMetadataUpdate).toMatchObject({ description: 'docs' }); + + handle.update({ title: 'Renamed' }); + const [afterTitleUpdate] = await listOf(server, 'resources/list', 'resources'); + expect(afterTitleUpdate).toMatchObject({ title: 'Renamed' }); + }); + + it('resources/templates/list keeps the title after update({ metadata }) and honours a later update({ title })', async () => { + const server = new McpServer({ name: 'titles', version: '0' }); + const handle = server.registerResource( + 'thing', + new ResourceTemplate('mem://things/{id}', { list: undefined }), + { title: 'Thing', mimeType: 'text/plain' }, + async () => ({ contents: [] }) + ); + + const [registered] = await listOf(server, 'resources/templates/list', 'resourceTemplates'); + expect(registered).toMatchObject({ name: 'thing', title: 'Thing' }); + + handle.update({ metadata: { mimeType: 'text/plain', description: 'things' } }); + const [afterMetadataUpdate] = await listOf(server, 'resources/templates/list', 'resourceTemplates'); + expect(afterMetadataUpdate).toMatchObject({ name: 'thing', title: 'Thing' }); + + handle.update({ title: 'Renamed thing' }); + const [afterTitleUpdate] = await listOf(server, 'resources/templates/list', 'resourceTemplates'); + expect(afterTitleUpdate).toMatchObject({ title: 'Renamed thing' }); + }); + + it('a template registered with a title still titles its listed instances after a metadata-only update', async () => { + const server = new McpServer({ name: 'titles', version: '0' }); + const template = new ResourceTemplate('mem://items/{id}', { + list: async () => ({ resources: [{ uri: 'mem://items/1', name: 'one' }] }) + }); + const handle = server.registerResource('item', template, { title: 'Item' }, async () => ({ contents: [] })); + + const [listed] = await listOf(server, 'resources/list', 'resources'); + expect(listed).toMatchObject({ uri: 'mem://items/1', name: 'one', title: 'Item' }); + + handle.update({ metadata: {} }); + const [afterMetadataUpdate] = await listOf(server, 'resources/list', 'resources'); + expect(afterMetadataUpdate).toMatchObject({ uri: 'mem://items/1', title: 'Item' }); + }); + + it('metadata still wins when it carries its own title', async () => { + const server = new McpServer({ name: 'titles', version: '0' }); + const handle = server.registerResource('docs', 'mem://docs', { title: 'Docs' }, async () => ({ contents: [] })); + + handle.update({ metadata: { title: 'From metadata' } }); + const [listed] = await listOf(server, 'resources/list', 'resources'); + expect(listed).toMatchObject({ title: 'From metadata' }); + }); +}); From 8d6ef03a81dcd60281a9663944ed89f9cd2e1281 Mon Sep 17 00:00:00 2001 From: Benoit P Date: Thu, 29 Oct 2026 16:07:03 -0700 Subject: [PATCH 2/2] fix(server): mirror a metadata-carried resource title onto the handle A metadata-only update replaces the metadata carrier wholesale. When the incoming metadata carried a title, the read-time fallback still pointed at the registration title, so a later replacement that omitted the title advertised the old value again and made the advertised title depend on write order. Both update paths now copy a metadata-carried title onto the registered resource or template. Tests pin the sequence register -> metadata title -> titleless metadata for both resources and templates. --- .changeset/resource-title-metadata-only.md | 2 ++ packages/server/src/server/mcp.ts | 16 +++++++-- .../server/test/server/resourceTitle.test.ts | 35 ++++++++++++++++++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/.changeset/resource-title-metadata-only.md b/.changeset/resource-title-metadata-only.md index b1752c331c..d0f08d966e 100644 --- a/.changeset/resource-title-metadata-only.md +++ b/.changeset/resource-title-metadata-only.md @@ -3,3 +3,5 @@ --- A resource or resource template registered with a `title` keeps advertising it in `resources/list` and `resources/templates/list` after `update({ metadata })`, which replaces the metadata carrier wholesale. Both list paths now derive the field from the registered title when the new metadata does not carry one; metadata that does carry a `title` still wins. + +`update({ metadata })` also mirrors a metadata-carried `title` onto the registered resource or template, so the fallback is always the last title written rather than the one from registration. Without that, `update({ metadata: { title: 'X' } })` followed by `update({ metadata: {} })` advertised the registration title again instead of `X`, which made the advertised title depend on the order of previous writes. diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index e6eee3e311..d6d203a64a 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -788,7 +788,13 @@ 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; + // A metadata-carried title is authoritative on the read paths, so mirror it onto the + // handle as well. Otherwise a later metadata replacement that omits the title falls + // back to a stale handle value and re-advertises a title the caller superseded. + if (updates.metadata.title !== undefined) registeredResource.title = updates.metadata.title; + } if (updates.title !== undefined) { registeredResource.title = updates.title; registeredResource.metadata = { ...registeredResource.metadata, title: updates.title }; @@ -829,7 +835,13 @@ 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; + // Same mirror as `_createRegisteredResource`: the metadata-carried title wins on the + // read paths, so the handle has to follow it or a later titleless replacement + // resurrects the previous value. + if (updates.metadata.title !== undefined) registeredResourceTemplate.title = updates.metadata.title; + } if (updates.title !== undefined) { registeredResourceTemplate.title = updates.title; registeredResourceTemplate.metadata = { ...registeredResourceTemplate.metadata, title: updates.title }; diff --git a/packages/server/test/server/resourceTitle.test.ts b/packages/server/test/server/resourceTitle.test.ts index b64d19c771..877d6c51e2 100644 --- a/packages/server/test/server/resourceTitle.test.ts +++ b/packages/server/test/server/resourceTitle.test.ts @@ -7,7 +7,9 @@ * (the handle and the metadata carrier), but `update({ metadata })` replaces the * carrier wholesale, so a metadata-only update used to drop the advertised title * even though the handle still held it. Both read paths now derive the field, with - * metadata winning when it carries one and the handle value as the fallback. + * metadata winning when it carries one and the handle value as the fallback, and both + * update paths mirror a metadata-carried title onto the handle so the fallback can + * never be a title the caller has already superseded. */ import { describe, expect, it } from 'vitest'; @@ -86,4 +88,35 @@ describe('resource title survives a metadata-only update', () => { const [listed] = await listOf(server, 'resources/list', 'resources'); expect(listed).toMatchObject({ title: 'From metadata' }); }); + + it('a later metadata replacement that omits the title keeps the last written one', async () => { + const server = new McpServer({ name: 'titles', version: '0' }); + const handle = server.registerResource('docs', 'mem://docs', { title: 'Docs' }, async () => ({ contents: [] })); + + handle.update({ metadata: { title: 'X' } }); + const [afterMetadataTitle] = await listOf(server, 'resources/list', 'resources'); + expect(afterMetadataTitle).toMatchObject({ title: 'X' }); + + handle.update({ metadata: {} }); + const [afterTitlelessMetadata] = await listOf(server, 'resources/list', 'resources'); + expect(afterTitlelessMetadata).toMatchObject({ title: 'X' }); + }); + + it('applies the same rule to templates', async () => { + const server = new McpServer({ name: 'titles', version: '0' }); + const handle = server.registerResource( + 'thing', + new ResourceTemplate('mem://things/{id}', { list: undefined }), + { title: 'Thing' }, + async () => ({ contents: [] }) + ); + + handle.update({ metadata: { title: 'Y' } }); + const [afterMetadataTitle] = await listOf(server, 'resources/templates/list', 'resourceTemplates'); + expect(afterMetadataTitle).toMatchObject({ title: 'Y' }); + + handle.update({ metadata: {} }); + const [afterTitlelessMetadata] = await listOf(server, 'resources/templates/list', 'resourceTemplates'); + expect(afterTitlelessMetadata).toMatchObject({ title: 'Y' }); + }); });