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

Fix `remove()` being a silent no-op after a resource, resource template, or prompt
has been renamed via `update()`. The closures for these three registration types
captured the original registry key and never reassigned it after a rename, so
`remove()` deleted the stale key instead of the one the entry now lives under —
leaving the entry listed and callable, with a `list_changed` notification firing
regardless. `RegisteredTool` already tracked its current key correctly; resources,
resource templates, and prompts now do the same.

Fixes #2723
17 changes: 14 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; // track the current registry key
}
}
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; // track the current registry key
}
}
if (updates.title !== undefined) registeredResourceTemplate.title = updates.title;
if (updates.template !== undefined) registeredResourceTemplate.resourceTemplate = updates.template;
Expand Down Expand Up @@ -757,7 +763,12 @@ 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;
// Tracks the current registry key; also feeds createPromptHandler() below, so a
// rename followed by a schema change regenerates against the new name.
name = updates.name;
}
}
if (updates.title !== undefined) registeredPrompt.title = updates.title;
if (updates.description !== undefined) registeredPrompt.description = updates.description;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Regression test for https://github.com/modelcontextprotocol/typescript-sdk/issues/2723
*
* The `update` closure for prompts, resources and resource templates captured the
* registration key and never reassigned it, so after a rename `remove()` deleted the
* vacated key and left the live entry registered and callable. `RegisteredTool`
* already reassigned its key; these three did not.
*/
import { Client } from '@modelcontextprotocol/client';
import { InMemoryTransport } from '@modelcontextprotocol/core-internal';
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server';

describe('Issue #2723: remove() after rename is a no-op', () => {
test('removes a prompt after it has been renamed', async () => {
const server = new McpServer({ name: 'test', version: '1.0.0' });
const prompt = server.registerPrompt('original', { description: 'x' }, async () => ({ messages: [] }));
prompt.update({ name: 'renamed' });
prompt.remove();

const client = new Client({ name: 'client', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

const result = await client.listPrompts();
expect(result.prompts).toHaveLength(0);
});

test('removes a resource after it has been renamed', async () => {
const server = new McpServer({ name: 'test', version: '1.0.0' });
const resource = server.registerResource('test', 'test://original', {}, async () => ({ contents: [] }));
resource.update({ uri: 'test://renamed' });
resource.remove();

const client = new Client({ name: 'client', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

const result = await client.listResources();
expect(result.resources).toHaveLength(0);
});

test('removes a resource template after it has been renamed', async () => {
const server = new McpServer({ name: 'test', version: '1.0.0' });
const template = server.registerResource('original', new ResourceTemplate('test://{id}', { list: undefined }), {}, async () => ({
contents: []
}));
template.update({ name: 'renamed' });
template.remove();

const client = new Client({ name: 'client', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

const result = await client.listResourceTemplates();
expect(result.resourceTemplates).toHaveLength(0);
});

test('should remove a prompt that was renamed and renamed back', async () => {
const server = new McpServer({ name: 'test', version: '1.0.0' });
const prompt = server.registerPrompt('original', { description: 'x' }, async () => ({ messages: [] }));
prompt.update({ name: 'renamed' });
prompt.update({ name: 'original' });
prompt.remove();

const client = new Client({ name: 'client', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

const result = await client.listPrompts();
expect(result.prompts).toHaveLength(0);
});
});
Loading