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
5 changes: 5 additions & 0 deletions .changeset/sync-resource-metadata-title.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 19 additions & 2 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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 };
Expand Down
97 changes: 97 additions & 0 deletions packages/server/test/server/resourceMetadataTitleSync.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> {
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<string, unknown> };
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');
});
});
Loading