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
11 changes: 11 additions & 0 deletions .changeset/registry-key-on-rename.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@modelcontextprotocol/server': patch
---

Keep the registry key current when a prompt, resource or resource template handle is renamed. The `update` closure captured the registration key and never reassigned it, so after one rename it was pointing at a key the entry no longer occupied.

- `remove()` became a silent no-op. It deletes the captured key, which is already vacant, and the entry stays in `prompts/list` / `resources/list` / `resources/templates/list` and stays callable. `list_changed` still fires, so clients are told the list changed when it did not.
- A second rename left the intermediate key registered, aliasing one entry under two names — both listed, both live.
- Renaming back to the original name silently did nothing, because the `updates.name !== name` guard compared against the stale captured value and skipped the whole block.

`RegisteredTool` already tracked the current key and is unaffected.
15 changes: 12 additions & 3 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,10 @@ export class McpServer {
update: updates => {
if (updates.uri !== undefined && updates.uri !== uri) {
delete this._registeredResources[uri];
if (updates.uri) this._registeredResources[updates.uri] = registeredResource;
if (updates.uri) {
this._registeredResources[updates.uri] = registeredResource;
uri = updates.uri;
}
}
if (updates.name !== undefined) registeredResource.name = updates.name;
if (updates.title !== undefined) registeredResource.title = updates.title;
Expand Down Expand Up @@ -708,7 +711,10 @@ export class McpServer {
update: updates => {
if (updates.name !== undefined && updates.name !== name) {
delete this._registeredResourceTemplates[name];
if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate;
if (updates.name) {
this._registeredResourceTemplates[updates.name] = registeredResourceTemplate;
name = updates.name;
}
}
if (updates.title !== undefined) registeredResourceTemplate.title = updates.title;
if (updates.template !== undefined) registeredResourceTemplate.resourceTemplate = updates.template;
Expand Down Expand Up @@ -757,7 +763,10 @@ export class McpServer {
update: updates => {
if (updates.name !== undefined && updates.name !== name) {
delete this._registeredPrompts[name];
if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt;
if (updates.name) {
this._registeredPrompts[updates.name] = registeredPrompt;
name = updates.name;
}
}
if (updates.title !== undefined) registeredPrompt.title = updates.title;
if (updates.description !== undefined) registeredPrompt.description = updates.description;
Expand Down
131 changes: 131 additions & 0 deletions test/integration/test/server/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2385,6 +2385,93 @@ describe('Zod v4', () => {
expect(result2.resourceTemplates).toHaveLength(0);
});

/***
* Test: Changing a Resource URI Twice Leaves Only the Latest
*/
test('should not leave the intermediate uri registered after two uri changes', async () => {
const mcpServer = new McpServer({
name: 'test server',
version: '1.0'
});
const client = new Client({
name: 'test client',
version: '1.0'
});

const resource = mcpServer.registerResource('test', 'test://first', {}, async () => ({
contents: [
{
uri: 'test://first',
text: 'Content'
}
]
}));

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);

resource.update({ uri: 'test://second' });
resource.update({ uri: 'test://third' });

const result = await client.request({ method: 'resources/list' });

expect(result.resources.map(r => r.uri)).toEqual(['test://third']);

// remove() must still find the resource under its current uri
resource.remove();

const result2 = await client.request({ method: 'resources/list' });

expect(result2.resources).toHaveLength(0);
});

/***
* Test: Renaming a Resource Template Twice Leaves Only the Latest
*/
test('should not leave the intermediate name registered after two template renames', async () => {
const mcpServer = new McpServer({
name: 'test server',
version: '1.0'
});
const client = new Client({
name: 'test client',
version: '1.0'
});

const resourceTemplate = mcpServer.registerResource(
'first',
new ResourceTemplate('test://resource/{id}', { list: undefined }),
{},
async uri => ({
contents: [
{
uri: uri.href,
text: 'Template content'
}
]
})
);

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);

resourceTemplate.update({ name: 'second' });
resourceTemplate.update({ name: 'third' });

const result = await client.request({ method: 'resources/templates/list' });

expect(result.resourceTemplates.map(t => t.name)).toEqual(['third']);

// remove() must still find the template under its current name
resourceTemplate.remove();

const result2 = await client.request({ method: 'resources/templates/list' });

expect(result2.resourceTemplates).toHaveLength(0);
});

/***
* Test: Resource Registration with Metadata
*/
Expand Down Expand Up @@ -3431,6 +3518,50 @@ describe('Zod v4', () => {
expect(result.prompts[0]!.name).toBe('prompt2');
});

/***
* Test: Renaming a Prompt Twice Leaves Only the Latest
*/
test('should not leave the intermediate name registered after two renames', async () => {
const mcpServer = new McpServer({
name: 'test server',
version: '1.0'
});
const client = new Client({
name: 'test client',
version: '1.0'
});

const prompt = mcpServer.registerPrompt('first', {}, async () => ({
messages: [
{
role: 'assistant',
content: {
type: 'text',
text: 'Response'
}
}
]
}));

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);

prompt.update({ name: 'second' });
prompt.update({ name: 'third' });

const result = await client.request({ method: 'prompts/list' });

expect(result.prompts.map(p => p.name)).toEqual(['third']);

// remove() must still find the prompt under its current name
prompt.remove();

const result2 = await client.request({ method: 'prompts/list' });

expect(result2.prompts).toHaveLength(0);
});

/***
* Test: Prompt Registration with Arguments Schema
*/
Expand Down
Loading