Skip to content

Commit 875ed7f

Browse files
committed
feat: add remove_resource and remove_prompt to MCPServer, address PR review concerns
Add remove_resource() and remove_prompt() methods to MCPServer to match the existing remove_tool() API, enabling dynamic deprovisioning of all resource types for multi-tenant servers. PR review fixes: - Fix heading grammar: "Simple Registration of..." (concern #1) - Add private API warning for _lowlevel_server usage in example (concern #3) - Clarify example server needs TokenVerifier for production (concern #4) - Guard offboard_tenant against KeyError for unprovisioned tools (concern #5) - Add remove_resource/remove_prompt to MCPServer and docs (concern #6) Tests added for both single-tenant and multi-tenant remove operations, including cross-tenant isolation verification.
1 parent 396ae66 commit 875ed7f

7 files changed

Lines changed: 258 additions & 7 deletions

File tree

docs/migration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,7 @@ Key additions:
888888
- `AccessToken.tenant_id` — carries tenant identity in OAuth tokens
889889
- `Context.tenant_id` — available in tool, resource, and prompt handlers
890890
- `server.add_tool(fn, tenant_id="...")`, `server.add_resource(r, tenant_id="...")`, `server.add_prompt(p, tenant_id="...")` — register tenant-scoped tools, resources, and prompts
891+
- `server.remove_tool(name, tenant_id="...")`, `server.remove_resource(uri, tenant_id="...")`, `server.remove_prompt(name, tenant_id="...")` — remove tenant-scoped tools, resources, and prompts
891892
- `StreamableHTTPSessionManager` — validates tenant identity on every request and prevents cross-tenant session access
892893

893894
All APIs default to `tenant_id=None`, preserving backward compatibility for single-tenant servers.

docs/multi-tenancy.md

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ flowchart TD
4747

4848
## Usage
4949

50-
### Simple Registering Tenant-Scoped Tools, Resources, and Prompts
50+
### Simple Registration of Tenant-Scoped Tools, Resources, and Prompts
5151

5252
Use the `tenant_id` parameter when adding tools, resources, or prompts:
5353

@@ -115,11 +115,24 @@ def onboard_tenant(server: MCPServer, tenant_id: str, plan: str) -> None:
115115

116116

117117
def offboard_tenant(server: MCPServer, tenant_id: str) -> None:
118-
"""Remove all tools when a tenant is deprovisioned."""
119-
server.remove_tool("search_docs", tenant_id=tenant_id)
120-
server.remove_tool("get_status", tenant_id=tenant_id)
121-
server.remove_tool("run_analytics", tenant_id=tenant_id)
122-
server.remove_tool("export_data", tenant_id=tenant_id)
118+
"""Remove all capabilities when a tenant is deprovisioned."""
119+
for tool_name in ["search_docs", "get_status", "run_analytics", "export_data"]:
120+
try:
121+
server.remove_tool(tool_name, tenant_id=tenant_id)
122+
except KeyError:
123+
pass # Tool was never provisioned (e.g. free-plan tenant)
124+
125+
for uri in ["data://docs", "data://status"]:
126+
try:
127+
server.remove_resource(uri, tenant_id=tenant_id)
128+
except ValueError:
129+
pass
130+
131+
for prompt_name in ["assistant"]:
132+
try:
133+
server.remove_prompt(prompt_name, tenant_id=tenant_id)
134+
except ValueError:
135+
pass
123136
```
124137

125138
#### Plugin Systems
@@ -141,7 +154,7 @@ def uninstall_plugin(server: MCPServer, tenant_id: str, plugin: str) -> None:
141154
server.remove_tool(name, tenant_id=tenant_id)
142155
```
143156

144-
All dynamic changes take effect immediately — the next `list_tools` request from that tenant will reflect the updated set. Other tenants are unaffected.
157+
All dynamic changes take effect immediately — the next `list_tools`, `list_resources`, or `list_prompts` request from that tenant will reflect the updated set. Other tenants are unaffected.
145158

146159
### Accessing Tenant ID in Handlers
147160

examples/servers/simple-multi-tenant/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ from mcp_simple_multi_tenant.server import create_server
5151

5252
async def main():
5353
server = create_server()
54+
# NOTE: _lowlevel_server is a private API used here for in-memory testing only.
55+
# In production, use HTTP transport with server.run() and a TokenVerifier instead.
5456
actual = server._lowlevel_server
5557

5658
async with create_client_server_memory_streams() as (client_streams, server_streams):

examples/servers/simple-multi-tenant/mcp_simple_multi_tenant/server.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ def create_server() -> MCPServer:
3232
Acme is an analytics company; Globex is a content company.
3333
"""
3434

35+
# NOTE: This example demonstrates tenant-scoped registration only.
36+
# Without AuthSettings and a TokenVerifier, an HTTP client connecting to
37+
# this server will have no tenant context set (tenant_id=None) and will
38+
# see no tools/resources/prompts. In production, configure a TokenVerifier
39+
# and AuthSettings so that tenant_id is extracted from bearer tokens
40+
# automatically. See test_multi_tenancy_oauth_e2e.py for the full
41+
# auth-enabled pattern.
3542
server = MCPServer("multi-tenant-demo")
3643

3744
# -- Tenant "acme" (analytics company) ---------------------------------

src/mcp/server/mcpserver/server.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,18 @@ def add_resource(self, resource: Resource, *, tenant_id: str | None = None) -> N
632632
"""
633633
self._resource_manager.add_resource(resource, tenant_id=tenant_id)
634634

635+
def remove_resource(self, uri: AnyUrl | str, *, tenant_id: str | None = None) -> None:
636+
"""Remove a resource from the server by URI.
637+
638+
Args:
639+
uri: The URI of the resource to remove
640+
tenant_id: Optional tenant scope for the resource
641+
642+
Raises:
643+
ValueError: If the resource does not exist
644+
"""
645+
self._resource_manager.remove_resource(uri, tenant_id=tenant_id)
646+
635647
def resource(
636648
self,
637649
uri: str,
@@ -753,6 +765,18 @@ def add_prompt(self, prompt: Prompt, *, tenant_id: str | None = None) -> None:
753765
"""
754766
self._prompt_manager.add_prompt(prompt, tenant_id=tenant_id)
755767

768+
def remove_prompt(self, name: str, *, tenant_id: str | None = None) -> None:
769+
"""Remove a prompt from the server by name.
770+
771+
Args:
772+
name: The name of the prompt to remove
773+
tenant_id: Optional tenant scope for the prompt
774+
775+
Raises:
776+
ValueError: If the prompt does not exist
777+
"""
778+
self._prompt_manager.remove_prompt(name, tenant_id=tenant_id)
779+
756780
def prompt(
757781
self,
758782
name: str | None = None,

tests/server/mcpserver/test_multi_tenancy_server.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,85 @@ def my_tool() -> str: # pragma: no cover
252252
assert len(tools_b) == 1
253253

254254

255+
async def test_remove_resource_with_tenant_id():
256+
"""remove_resource respects tenant scope and does not affect other tenants."""
257+
server = MCPServer("test")
258+
259+
resource_a = FunctionResource(uri="file:///data", name="data-a", fn=lambda: "a")
260+
resource_b = FunctionResource(uri="file:///data", name="data-b", fn=lambda: "b")
261+
262+
server.add_resource(resource_a, tenant_id="tenant-a")
263+
server.add_resource(resource_b, tenant_id="tenant-b")
264+
265+
server.remove_resource("file:///data", tenant_id="tenant-a")
266+
267+
resources_a = await server.list_resources(tenant_id="tenant-a")
268+
resources_b = await server.list_resources(tenant_id="tenant-b")
269+
270+
assert len(resources_a) == 0
271+
assert len(resources_b) == 1
272+
assert resources_b[0].name == "data-b"
273+
274+
# Tenant B can still read their resource
275+
results = await server.read_resource("file:///data", tenant_id="tenant-b")
276+
contents = list(results)
277+
assert len(contents) == 1
278+
assert contents[0].content == "b"
279+
280+
# Tenant A's resource is gone
281+
from mcp.server.mcpserver.exceptions import ResourceError
282+
283+
with pytest.raises(ResourceError, match="Unknown resource"):
284+
await server.read_resource("file:///data", tenant_id="tenant-a")
285+
286+
287+
async def test_remove_resource_nonexistent_raises():
288+
"""Removing a non-existent resource raises ValueError."""
289+
server = MCPServer("test")
290+
291+
with pytest.raises(ValueError, match="Unknown resource"):
292+
server.remove_resource("file:///nope", tenant_id="tenant-a")
293+
294+
295+
async def test_remove_prompt_with_tenant_id():
296+
"""remove_prompt respects tenant scope and does not affect other tenants."""
297+
server = MCPServer("test")
298+
299+
async def prompt_a() -> str:
300+
return "Hello from A"
301+
302+
async def prompt_b() -> str:
303+
return "Hello from B"
304+
305+
server.add_prompt(Prompt.from_function(prompt_a, name="greet"), tenant_id="tenant-a")
306+
server.add_prompt(Prompt.from_function(prompt_b, name="greet"), tenant_id="tenant-b")
307+
308+
server.remove_prompt("greet", tenant_id="tenant-a")
309+
310+
prompts_a = await server.list_prompts(tenant_id="tenant-a")
311+
prompts_b = await server.list_prompts(tenant_id="tenant-b")
312+
313+
assert len(prompts_a) == 0
314+
assert len(prompts_b) == 1
315+
316+
# Tenant B can still render their prompt
317+
result = await server.get_prompt("greet", tenant_id="tenant-b")
318+
assert result.messages is not None
319+
assert len(result.messages) > 0
320+
321+
# Tenant A's prompt is gone
322+
with pytest.raises(ValueError, match="Unknown prompt"):
323+
await server.get_prompt("greet", tenant_id="tenant-a")
324+
325+
326+
async def test_remove_prompt_nonexistent_raises():
327+
"""Removing a non-existent prompt raises ValueError."""
328+
server = MCPServer("test")
329+
330+
with pytest.raises(ValueError, match="Unknown prompt"):
331+
server.remove_prompt("nope", tenant_id="tenant-a")
332+
333+
255334
# --- Backward compatibility ---
256335

257336

tests/server/mcpserver/test_server.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1481,3 +1481,128 @@ async def test_report_progress_passes_related_request_id():
14811481
message="halfway",
14821482
related_request_id="req-abc-123",
14831483
)
1484+
1485+
1486+
# --- remove_resource / remove_prompt on MCPServer ---
1487+
1488+
1489+
async def test_remove_resource():
1490+
"""Test removing a resource from the server."""
1491+
mcp = MCPServer()
1492+
resource = FunctionResource(uri="resource://test", name="test", fn=lambda: "data")
1493+
mcp.add_resource(resource)
1494+
1495+
assert len(mcp._resource_manager.list_resources()) == 1
1496+
1497+
mcp.remove_resource("resource://test")
1498+
1499+
assert len(mcp._resource_manager.list_resources()) == 0
1500+
1501+
1502+
async def test_remove_nonexistent_resource():
1503+
"""Test that removing a non-existent resource raises ValueError."""
1504+
mcp = MCPServer()
1505+
1506+
with pytest.raises(ValueError, match="Unknown resource"):
1507+
mcp.remove_resource("resource://nope")
1508+
1509+
1510+
async def test_remove_resource_and_list():
1511+
"""Test that a removed resource doesn't appear in list_resources."""
1512+
mcp = MCPServer()
1513+
mcp.add_resource(FunctionResource(uri="resource://a", name="a", fn=lambda: "a"))
1514+
mcp.add_resource(FunctionResource(uri="resource://b", name="b", fn=lambda: "b"))
1515+
1516+
async with Client(mcp) as client:
1517+
resources = await client.list_resources()
1518+
assert len(resources.resources) == 2
1519+
1520+
mcp.remove_resource("resource://a")
1521+
1522+
async with Client(mcp) as client:
1523+
resources = await client.list_resources()
1524+
assert len(resources.resources) == 1
1525+
assert resources.resources[0].name == "b"
1526+
1527+
1528+
async def test_remove_resource_and_read():
1529+
"""Test that reading a removed resource fails."""
1530+
mcp = MCPServer()
1531+
mcp.add_resource(FunctionResource(uri="resource://test", name="test", fn=lambda: "data"))
1532+
1533+
async with Client(mcp) as client:
1534+
result = await client.read_resource("resource://test")
1535+
assert isinstance(result.contents[0], TextResourceContents)
1536+
assert result.contents[0].text == "data"
1537+
1538+
mcp.remove_resource("resource://test")
1539+
1540+
async with Client(mcp) as client:
1541+
with pytest.raises(MCPError, match="Unknown resource"):
1542+
await client.read_resource("resource://test")
1543+
1544+
1545+
async def test_remove_prompt():
1546+
"""Test removing a prompt from the server."""
1547+
mcp = MCPServer()
1548+
1549+
@mcp.prompt()
1550+
def greet() -> str: # pragma: no cover
1551+
return "Hello"
1552+
1553+
assert len(mcp._prompt_manager.list_prompts()) == 1
1554+
1555+
mcp.remove_prompt("greet")
1556+
1557+
assert len(mcp._prompt_manager.list_prompts()) == 0
1558+
1559+
1560+
async def test_remove_nonexistent_prompt():
1561+
"""Test that removing a non-existent prompt raises ValueError."""
1562+
mcp = MCPServer()
1563+
1564+
with pytest.raises(ValueError, match="Unknown prompt"):
1565+
mcp.remove_prompt("nope")
1566+
1567+
1568+
async def test_remove_prompt_and_list():
1569+
"""Test that a removed prompt doesn't appear in list_prompts."""
1570+
mcp = MCPServer()
1571+
1572+
@mcp.prompt()
1573+
def greet() -> str: # pragma: no cover
1574+
return "Hello"
1575+
1576+
@mcp.prompt()
1577+
def farewell() -> str: # pragma: no cover
1578+
return "Goodbye"
1579+
1580+
async with Client(mcp) as client:
1581+
prompts = await client.list_prompts()
1582+
assert len(prompts.prompts) == 2
1583+
1584+
mcp.remove_prompt("greet")
1585+
1586+
async with Client(mcp) as client:
1587+
prompts = await client.list_prompts()
1588+
assert len(prompts.prompts) == 1
1589+
assert prompts.prompts[0].name == "farewell"
1590+
1591+
1592+
async def test_remove_prompt_and_get():
1593+
"""Test that getting a removed prompt fails."""
1594+
mcp = MCPServer()
1595+
1596+
@mcp.prompt()
1597+
def greet() -> str:
1598+
return "Hello"
1599+
1600+
async with Client(mcp) as client:
1601+
result = await client.get_prompt("greet")
1602+
assert result.messages is not None
1603+
1604+
mcp.remove_prompt("greet")
1605+
1606+
async with Client(mcp) as client:
1607+
with pytest.raises(MCPError, match="Unknown prompt"):
1608+
await client.get_prompt("greet")

0 commit comments

Comments
 (0)