diff --git a/docs/servers/resources.md b/docs/servers/resources.md index 407f1d9ae1..7bebe55287 100644 --- a/docs/servers/resources.md +++ b/docs/servers/resources.md @@ -127,6 +127,10 @@ The same rule applies to anything else JSON-serialisable: a list, a Pydantic mod `BinaryResource`, `FileResource`, `HttpResource`, `DirectoryResource`) that you register with `mcp.add_resource(...)`. + Template resources can be registered programmatically too: build a `ResourceTemplate` + with `ResourceTemplate.from_function(...)` and call `mcp.add_resource_template(...)` + instead of decorating a function. + A client can also **subscribe** to a resource and be notified when it changes; that's the client's half of the story and it lives in **[The Client](../client/index.md)**. ## Recap diff --git a/src/mcp/server/mcpserver/resources/resource_manager.py b/src/mcp/server/mcpserver/resources/resource_manager.py index e56e3ba177..4a4b060248 100644 --- a/src/mcp/server/mcpserver/resources/resource_manager.py +++ b/src/mcp/server/mcpserver/resources/resource_manager.py @@ -57,6 +57,28 @@ def add_resource(self, resource: Resource) -> Resource: self._resources[str(resource.uri)] = resource return resource + def add_resource_template(self, template: ResourceTemplate) -> ResourceTemplate: + """Add a resource template to the manager. + + Args: + template: A ResourceTemplate instance to add. + + Returns: + The added template. If a template with the same uri_template + already exists, returns the existing template. + """ + logger.debug( + "Adding resource template", + extra={"uri_template": template.uri_template, "name": template.name}, + ) + existing = self._templates.get(template.uri_template) + if existing: + if self.warn_on_duplicate_resources: + logger.warning(f"Resource template already exists: {template.uri_template}") + return existing + self._templates[template.uri_template] = template + return template + def add_template( self, fn: Callable[..., Any], @@ -83,8 +105,7 @@ def add_template( meta=meta, security=security, ) - self._templates[template.uri_template] = template - return template + return self.add_resource_template(template) async def get_resource( self, uri: AnyUrl | str, context: Context[LifespanContextT, RequestT] diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index b3a3cb3bcd..3789a55a8d 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -79,6 +79,7 @@ Resource, ResourceManager, ResourceSecurity, + ResourceTemplate, ) from mcp.server.mcpserver.tools import Tool, ToolManager from mcp.server.mcpserver.utilities.context_injection import find_context_parameter @@ -730,6 +731,14 @@ def add_resource(self, resource: Resource) -> None: """ self._resource_manager.add_resource(resource) + def add_resource_template(self, template: ResourceTemplate) -> None: + """Add a resource template to the server. + + Args: + template: A ResourceTemplate instance to add. + """ + self._resource_manager.add_resource_template(template) + def resource( self, uri: str, diff --git a/tests/server/mcpserver/resources/test_resource_manager.py b/tests/server/mcpserver/resources/test_resource_manager.py index bbb7de7eb8..831a470777 100644 --- a/tests/server/mcpserver/resources/test_resource_manager.py +++ b/tests/server/mcpserver/resources/test_resource_manager.py @@ -90,7 +90,7 @@ def greet(name: str) -> str: return f"Hello, {name}!" template = ResourceTemplate.from_function(fn=greet, uri_template="greet://{name}", name="greeter") - manager._templates[template.uri_template] = template + manager.add_resource_template(template) resource = await manager.get_resource(AnyUrl("greet://world"), Context()) assert isinstance(resource, FunctionResource) @@ -106,6 +106,45 @@ async def test_get_unknown_resource(): await manager.get_resource(AnyUrl("unknown://test"), Context()) +def test_add_resource_template(): + """Test adding a template through add_resource_template().""" + manager = ResourceManager() + template = ResourceTemplate.from_function(fn=get_item, uri_template="resource://items/{id}", name="items") + added = manager.add_resource_template(template) + assert added == template + assert manager.list_templates() == [template] + + +def test_add_duplicate_resource_template(caplog: pytest.LogCaptureFixture): + """Adding a template whose uri_template is already registered returns the existing template and warns.""" + manager = ResourceManager() + first = ResourceTemplate.from_function(fn=get_item, uri_template="resource://items/{id}", name="items") + second = ResourceTemplate.from_function(fn=get_item, uri_template="resource://items/{id}", name="other") + assert first is not second + + added = manager.add_resource_template(first) + assert added is first + + returned = manager.add_resource_template(second) + assert returned is first + assert "Resource template already exists: resource://items/{id}" in caplog.text + assert manager.list_templates() == [first] + + +def test_disable_warn_on_duplicate_resource_templates(caplog: pytest.LogCaptureFixture): + """Adding a duplicate template does not warn when warnings are disabled.""" + manager = ResourceManager(warn_on_duplicate_resources=False) + first = ResourceTemplate.from_function(fn=get_item, uri_template="resource://items/{id}", name="items") + second = ResourceTemplate.from_function(fn=get_item, uri_template="resource://items/{id}", name="other") + + manager.add_resource_template(first) + returned = manager.add_resource_template(second) + + assert returned is first + assert "Resource template already exists" not in caplog.text + assert manager.list_templates() == [first] + + def test_list_resources(temp_file: Path): """Test listing all resources.""" manager = ResourceManager() diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index f9055a4ed4..bff3274fbf 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -53,6 +53,7 @@ from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError from mcp.server.mcpserver.prompts.base import Message, UserMessage from mcp.server.mcpserver.resources import FileResource, FunctionResource +from mcp.server.mcpserver.resources import ResourceTemplate as ServerResourceTemplate from mcp.server.mcpserver.utilities.types import Audio, Image from mcp.server.subscriptions import ( InMemorySubscriptionBus, @@ -1541,6 +1542,31 @@ def read_doc(path: str) -> str: assert [t.uri_template for t in templates] == ["file://docs/{+path}"] +async def test_add_resource_template(): + """A ResourceTemplate registered via add_resource_template() is listed and readable.""" + mcp = MCPServer() + + def get_data(name: str) -> str: + return f"Data for {name}" + + template = ServerResourceTemplate.from_function(fn=get_data, uri_template="resource://{name}/data") + mcp.add_resource_template(template) + + templates = await mcp.list_resource_templates() + assert templates == snapshot( + [ + ResourceTemplate( + name="get_data", uri_template="resource://{name}/data", description="", mime_type="text/plain" + ) + ] + ) + + async with Client(mcp) as client: + result = await client.read_resource("resource://test/data") + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].text == "Data for test" + + async def test_resource_decorator_rejects_malformed_template(): mcp = MCPServer() with pytest.raises(InvalidUriTemplate, match="Unclosed expression"):