Skip to content

Commit ca281f8

Browse files
authored
Merge pull request #10 from andylim-duo/fix/issue-8-manager-followups
refactor(managers): O(1) tenant lookups and missing APIs
2 parents 2941534 + 2b9a645 commit ca281f8

6 files changed

Lines changed: 248 additions & 73 deletions

File tree

src/mcp/server/mcpserver/prompts/manager.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,29 @@
1717
class PromptManager:
1818
"""Manages MCPServer prompts with optional tenant-scoped storage.
1919
20-
Prompts are stored in a dict keyed by ``(tenant_id, prompt_name)``.
20+
Prompts are stored in a nested dict: ``{tenant_id: {prompt_name: Prompt}}``.
2121
This allows the same prompt name to exist independently under different
22-
tenants. When ``tenant_id`` is ``None`` (the default), prompts live in
23-
a global scope, preserving backward compatibility with single-tenant usage.
22+
tenants with O(1) lookups per tenant. When ``tenant_id`` is ``None``
23+
(the default), prompts live in a global scope, preserving backward
24+
compatibility with single-tenant usage.
25+
26+
Note: This class is not thread-safe. It is designed to run within a
27+
single-threaded async event loop, where all synchronous mutations
28+
execute atomically. Do not share instances across OS threads without
29+
external synchronization.
2430
"""
2531

2632
def __init__(self, warn_on_duplicate_prompts: bool = True):
27-
self._prompts: dict[tuple[str | None, str], Prompt] = {}
33+
self._prompts: dict[str | None, dict[str, Prompt]] = {}
2834
self.warn_on_duplicate_prompts = warn_on_duplicate_prompts
2935

3036
def get_prompt(self, name: str, *, tenant_id: str | None = None) -> Prompt | None:
3137
"""Get prompt by name, optionally scoped to a tenant."""
32-
return self._prompts.get((tenant_id, name))
38+
return self._prompts.get(tenant_id, {}).get(name)
3339

3440
def list_prompts(self, *, tenant_id: str | None = None) -> list[Prompt]:
3541
"""List all registered prompts for a given tenant scope."""
36-
return [prompt for (tid, _), prompt in self._prompts.items() if tid == tenant_id]
42+
return list(self._prompts.get(tenant_id, {}).values())
3743

3844
def add_prompt(
3945
self,
@@ -42,18 +48,25 @@ def add_prompt(
4248
tenant_id: str | None = None,
4349
) -> Prompt:
4450
"""Add a prompt to the manager, optionally scoped to a tenant."""
45-
46-
# Check for duplicates
47-
key = (tenant_id, prompt.name)
48-
existing = self._prompts.get(key)
51+
scope = self._prompts.setdefault(tenant_id, {})
52+
existing = scope.get(prompt.name)
4953
if existing:
5054
if self.warn_on_duplicate_prompts:
5155
logger.warning(f"Prompt already exists: {prompt.name}")
5256
return existing
5357

54-
self._prompts[key] = prompt
58+
scope[prompt.name] = prompt
5559
return prompt
5660

61+
def remove_prompt(self, name: str, *, tenant_id: str | None = None) -> None:
62+
"""Remove a prompt by name, optionally scoped to a tenant."""
63+
scope = self._prompts.get(tenant_id, {})
64+
if name not in scope:
65+
raise ValueError(f"Unknown prompt: {name}")
66+
del scope[name]
67+
if not scope and tenant_id in self._prompts:
68+
del self._prompts[tenant_id]
69+
5770
async def render_prompt(
5871
self,
5972
name: str,

src/mcp/server/mcpserver/resources/resource_manager.py

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,23 @@
2222
class ResourceManager:
2323
"""Manages MCPServer resources with optional tenant-scoped storage.
2424
25-
Resources and templates are stored in dicts keyed by
26-
``(tenant_id, uri_string)`` and ``(tenant_id, uri_template)``
27-
respectively. This allows the same URI to exist independently under
28-
different tenants. When ``tenant_id`` is ``None`` (the default),
25+
Resources and templates are stored in nested dicts:
26+
``{tenant_id: {uri_string: Resource}}`` and
27+
``{tenant_id: {uri_template: ResourceTemplate}}`` respectively.
28+
This allows the same URI to exist independently under different tenants
29+
with O(1) lookups per tenant. When ``tenant_id`` is ``None`` (the default),
2930
entries live in a global scope, preserving backward compatibility
3031
with single-tenant usage.
32+
33+
Note: This class is not thread-safe. It is designed to run within a
34+
single-threaded async event loop, where all synchronous mutations
35+
execute atomically. Do not share instances across OS threads without
36+
external synchronization.
3137
"""
3238

3339
def __init__(self, warn_on_duplicate_resources: bool = True):
34-
self._resources: dict[tuple[str | None, str], Resource] = {}
35-
self._templates: dict[tuple[str | None, str], ResourceTemplate] = {}
40+
self._resources: dict[str | None, dict[str, Resource]] = {}
41+
self._templates: dict[str | None, dict[str, ResourceTemplate]] = {}
3642
self.warn_on_duplicate_resources = warn_on_duplicate_resources
3743

3844
def add_resource(self, resource: Resource, *, tenant_id: str | None = None) -> Resource:
@@ -54,13 +60,14 @@ def add_resource(self, resource: Resource, *, tenant_id: str | None = None) -> R
5460
"resource_name": resource.name,
5561
},
5662
)
57-
key = (tenant_id, str(resource.uri))
58-
existing = self._resources.get(key)
63+
scope = self._resources.setdefault(tenant_id, {})
64+
uri_str = str(resource.uri)
65+
existing = scope.get(uri_str)
5966
if existing:
6067
if self.warn_on_duplicate_resources:
6168
logger.warning(f"Resource already exists: {resource.uri}")
6269
return existing
63-
self._resources[key] = resource
70+
scope[uri_str] = resource
6471
return resource
6572

6673
def add_template(
@@ -77,7 +84,12 @@ def add_template(
7784
*,
7885
tenant_id: str | None = None,
7986
) -> ResourceTemplate:
80-
"""Add a template from a function, optionally scoped to a tenant."""
87+
"""Add a template from a function, optionally scoped to a tenant.
88+
89+
Returns:
90+
The added template. If a template with the same URI template already
91+
exists, returns the existing template.
92+
"""
8193
template = ResourceTemplate.from_function(
8294
fn,
8395
uri_template=uri_template,
@@ -89,9 +101,25 @@ def add_template(
89101
annotations=annotations,
90102
meta=meta,
91103
)
92-
self._templates[(tenant_id, template.uri_template)] = template
104+
scope = self._templates.setdefault(tenant_id, {})
105+
existing = scope.get(template.uri_template)
106+
if existing:
107+
if self.warn_on_duplicate_resources:
108+
logger.warning(f"Resource template already exists: {template.uri_template}")
109+
return existing
110+
scope[template.uri_template] = template
93111
return template
94112

113+
def remove_resource(self, uri: AnyUrl | str, *, tenant_id: str | None = None) -> None:
114+
"""Remove a resource by URI, optionally scoped to a tenant."""
115+
uri_str = str(uri)
116+
scope = self._resources.get(tenant_id, {})
117+
if uri_str not in scope:
118+
raise ValueError(f"Unknown resource: {uri}")
119+
del scope[uri_str]
120+
if not scope and tenant_id in self._resources:
121+
del self._resources[tenant_id]
122+
95123
async def get_resource(
96124
self,
97125
uri: AnyUrl | str,
@@ -104,13 +132,12 @@ async def get_resource(
104132
logger.debug("Getting resource", extra={"uri": uri_str})
105133

106134
# First check concrete resources
107-
if resource := self._resources.get((tenant_id, uri_str)):
135+
resource = self._resources.get(tenant_id, {}).get(uri_str)
136+
if resource:
108137
return resource
109138

110139
# Then check templates for this tenant scope
111-
for (tid, _), template in self._templates.items():
112-
if tid != tenant_id:
113-
continue
140+
for template in self._templates.get(tenant_id, {}).values():
114141
if params := template.matches(uri_str):
115142
try:
116143
return await template.create_resource(uri_str, params, context=context)
@@ -121,12 +148,12 @@ async def get_resource(
121148

122149
def list_resources(self, *, tenant_id: str | None = None) -> list[Resource]:
123150
"""List all registered resources for a given tenant scope."""
124-
resources = [r for (tid, _), r in self._resources.items() if tid == tenant_id]
151+
resources = list(self._resources.get(tenant_id, {}).values())
125152
logger.debug("Listing resources", extra={"count": len(resources)})
126153
return resources
127154

128155
def list_templates(self, *, tenant_id: str | None = None) -> list[ResourceTemplate]:
129156
"""List all registered templates for a given tenant scope."""
130-
templates = [t for (tid, _), t in self._templates.items() if tid == tenant_id]
157+
templates = list(self._templates.get(tenant_id, {}).values())
131158
logger.debug("Listing templates", extra={"count": len(templates)})
132159
return templates

src/mcp/server/mcpserver/tools/tool_manager.py

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,16 @@
1818
class ToolManager:
1919
"""Manages MCPServer tools with optional tenant-scoped storage.
2020
21-
Tools are stored in a dict keyed by ``(tenant_id, tool_name)``.
21+
Tools are stored in a nested dict: ``{tenant_id: {tool_name: Tool}}``.
2222
This allows the same tool name to exist independently under different
23-
tenants. When ``tenant_id`` is ``None`` (the default), tools live in
24-
a global scope, preserving backward compatibility with single-tenant usage.
23+
tenants with O(1) lookups per tenant. When ``tenant_id`` is ``None``
24+
(the default), tools live in a global scope, preserving backward
25+
compatibility with single-tenant usage.
26+
27+
Note: This class is not thread-safe. It is designed to run within a
28+
single-threaded async event loop, where all synchronous mutations
29+
execute atomically. Do not share instances across OS threads without
30+
external synchronization.
2531
"""
2632

2733
def __init__(
@@ -30,23 +36,23 @@ def __init__(
3036
*,
3137
tools: list[Tool] | None = None,
3238
):
33-
self._tools: dict[tuple[str | None, str], Tool] = {}
39+
self._tools: dict[str | None, dict[str, Tool]] = {}
3440
if tools is not None:
41+
scope = self._tools.setdefault(None, {})
3542
for tool in tools:
36-
key = (None, tool.name)
37-
if warn_on_duplicate_tools and key in self._tools:
43+
if warn_on_duplicate_tools and tool.name in scope:
3844
logger.warning(f"Tool already exists: {tool.name}")
39-
self._tools[key] = tool
45+
scope[tool.name] = tool
4046

4147
self.warn_on_duplicate_tools = warn_on_duplicate_tools
4248

4349
def get_tool(self, name: str, *, tenant_id: str | None = None) -> Tool | None:
4450
"""Get tool by name, optionally scoped to a tenant."""
45-
return self._tools.get((tenant_id, name))
51+
return self._tools.get(tenant_id, {}).get(name)
4652

4753
def list_tools(self, *, tenant_id: str | None = None) -> list[Tool]:
4854
"""List all registered tools for a given tenant scope."""
49-
return [tool for (tid, _), tool in self._tools.items() if tid == tenant_id]
55+
return list(self._tools.get(tenant_id, {}).values())
5056

5157
def add_tool(
5258
self,
@@ -72,21 +78,23 @@ def add_tool(
7278
meta=meta,
7379
structured_output=structured_output,
7480
)
75-
key = (tenant_id, tool.name)
76-
existing = self._tools.get(key)
81+
scope = self._tools.setdefault(tenant_id, {})
82+
existing = scope.get(tool.name)
7783
if existing:
7884
if self.warn_on_duplicate_tools:
7985
logger.warning(f"Tool already exists: {tool.name}")
8086
return existing
81-
self._tools[key] = tool
87+
scope[tool.name] = tool
8288
return tool
8389

8490
def remove_tool(self, name: str, *, tenant_id: str | None = None) -> None:
8591
"""Remove a tool by name, optionally scoped to a tenant."""
86-
key = (tenant_id, name)
87-
if key not in self._tools:
92+
scope = self._tools.get(tenant_id, {})
93+
if name not in scope:
8894
raise ToolError(f"Unknown tool: {name}")
89-
del self._tools[key]
95+
del scope[name]
96+
if not scope and tenant_id in self._tools:
97+
del self._tools[tenant_id]
9098

9199
async def call_tool(
92100
self,

tests/server/mcpserver/conftest.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from collections.abc import Callable
2+
from typing import Any
3+
4+
import pytest
5+
6+
from mcp.server.mcpserver.context import Context
7+
8+
MakeContext = Callable[..., Context[Any, Any]]
9+
10+
11+
@pytest.fixture
12+
def make_context() -> MakeContext:
13+
"""Factory fixture for creating Context instances in tests.
14+
15+
Centralizes Context construction so that tests don't break if the
16+
Context.__init__ signature changes in later iterations.
17+
"""
18+
19+
def _make(**kwargs: Any) -> Context[Any, Any]:
20+
return Context(**kwargs)
21+
22+
return _make

tests/server/mcpserver/resources/test_resource_manager.py

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
import pytest
55
from pydantic import AnyUrl
66

7-
from mcp.server.mcpserver import Context
8-
from mcp.server.mcpserver.resources import FileResource, FunctionResource, ResourceManager, ResourceTemplate
7+
from mcp.server.mcpserver.resources import FileResource, FunctionResource, ResourceManager
8+
from tests.server.mcpserver.conftest import MakeContext
99

1010

1111
@pytest.fixture
@@ -78,7 +78,7 @@ def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog: pyte
7878
assert "Resource already exists" not in caplog.text
7979

8080
@pytest.mark.anyio
81-
async def test_get_resource(self, temp_file: Path):
81+
async def test_get_resource(self, temp_file: Path, make_context: MakeContext):
8282
"""Test getting a resource by URI."""
8383
manager = ResourceManager()
8484
resource = FileResource(
@@ -87,35 +87,30 @@ async def test_get_resource(self, temp_file: Path):
8787
path=temp_file,
8888
)
8989
manager.add_resource(resource)
90-
retrieved = await manager.get_resource(resource.uri, Context())
90+
retrieved = await manager.get_resource(resource.uri, make_context())
9191
assert retrieved == resource
9292

9393
@pytest.mark.anyio
94-
async def test_get_resource_from_template(self):
94+
async def test_get_resource_from_template(self, make_context: MakeContext):
9595
"""Test getting a resource through a template."""
9696
manager = ResourceManager()
9797

9898
def greet(name: str) -> str:
9999
return f"Hello, {name}!"
100100

101-
template = ResourceTemplate.from_function(
102-
fn=greet,
103-
uri_template="greet://{name}",
104-
name="greeter",
105-
)
106-
manager._templates[(None, template.uri_template)] = template
101+
manager.add_template(fn=greet, uri_template="greet://{name}", name="greeter")
107102

108-
resource = await manager.get_resource(AnyUrl("greet://world"), Context())
103+
resource = await manager.get_resource(AnyUrl("greet://world"), make_context())
109104
assert isinstance(resource, FunctionResource)
110105
content = await resource.read()
111106
assert content == "Hello, world!"
112107

113108
@pytest.mark.anyio
114-
async def test_get_unknown_resource(self):
109+
async def test_get_unknown_resource(self, make_context: MakeContext):
115110
"""Test getting a non-existent resource."""
116111
manager = ResourceManager()
117112
with pytest.raises(ValueError, match="Unknown resource"):
118-
await manager.get_resource(AnyUrl("unknown://test"), Context())
113+
await manager.get_resource(AnyUrl("unknown://test"), make_context())
119114

120115
def test_list_resources(self, temp_file: Path):
121116
"""Test listing all resources."""
@@ -175,3 +170,37 @@ def get_item(id: str) -> str: # pragma: no cover
175170
)
176171

177172
assert template.meta is None
173+
174+
def test_add_duplicate_template(self):
175+
"""Test adding the same template twice returns the existing one."""
176+
manager = ResourceManager()
177+
178+
def get_item(id: str) -> str: # pragma: no cover
179+
return f"Item {id}"
180+
181+
first = manager.add_template(fn=get_item, uri_template="resource://items/{id}")
182+
second = manager.add_template(fn=get_item, uri_template="resource://items/{id}")
183+
assert first is second
184+
assert len(manager.list_templates()) == 1
185+
186+
def test_warn_on_duplicate_template(self, caplog: pytest.LogCaptureFixture):
187+
"""Test warning on duplicate template."""
188+
manager = ResourceManager()
189+
190+
def get_item(id: str) -> str: # pragma: no cover
191+
return f"Item {id}"
192+
193+
manager.add_template(fn=get_item, uri_template="resource://items/{id}")
194+
manager.add_template(fn=get_item, uri_template="resource://items/{id}")
195+
assert "Resource template already exists" in caplog.text
196+
197+
def test_disable_warn_on_duplicate_template(self, caplog: pytest.LogCaptureFixture):
198+
"""Test disabling warning on duplicate template."""
199+
manager = ResourceManager(warn_on_duplicate_resources=False)
200+
201+
def get_item(id: str) -> str: # pragma: no cover
202+
return f"Item {id}"
203+
204+
manager.add_template(fn=get_item, uri_template="resource://items/{id}")
205+
manager.add_template(fn=get_item, uri_template="resource://items/{id}")
206+
assert "Resource template already exists" not in caplog.text

0 commit comments

Comments
 (0)