Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/resource-title-metadata-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@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.

`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.
28 changes: 24 additions & 4 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -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
});
Expand All @@ -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 };
Expand Down Expand Up @@ -780,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 };
Expand Down Expand Up @@ -821,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 };
Expand Down
122 changes: 122 additions & 0 deletions packages/server/test/server/resourceTitle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* 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, 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';

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<Array<Record<string, unknown>>> => {
const response = await invoke(server, { jsonrpc: '2.0', id: 1, method, params: {} }, LEGACY);
const body = (await response.json()) as { result: Record<string, Array<Record<string, unknown>>> };
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' });
});

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' });
});
});
Loading