diff --git a/.changeset/registry-key-on-rename.md b/.changeset/registry-key-on-rename.md new file mode 100644 index 0000000000..85b45a7074 --- /dev/null +++ b/.changeset/registry-key-on-rename.md @@ -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. diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index d2e40181e4..c323e3ad93 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -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; @@ -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; @@ -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; diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 4b9a3865f0..fe02c805e2 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -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 */ @@ -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 */