From 7453e70cd0465c7138dae354cc36d976665ecbe6 Mon Sep 17 00:00:00 2001 From: Tapan Chugh Date: Thu, 31 Jul 2025 17:27:11 -0700 Subject: [PATCH] Implement prefix based matching for list resources / templates methods --- .../mcp_simple_resource/server.py | 4 +- src/mcp/client/session.py | 18 ++- .../fastmcp/resources/resource_manager.py | 24 ++-- src/mcp/server/fastmcp/resources/templates.py | 38 ++++++ src/mcp/server/fastmcp/server.py | 21 +++- src/mcp/server/lowlevel/server.py | 12 +- src/mcp/types.py | 22 +++- tests/issues/test_152_resource_mime_type.py | 2 +- .../resources/test_resource_manager.py | 94 +++++++++++++++ .../resources/test_resource_template.py | 114 ++++++++++++++++++ tests/server/test_session.py | 2 +- tests/shared/test_memory.py | 3 +- 12 files changed, 323 insertions(+), 31 deletions(-) diff --git a/examples/servers/simple-resource/mcp_simple_resource/server.py b/examples/servers/simple-resource/mcp_simple_resource/server.py index cef29b851..57fc60b69 100644 --- a/examples/servers/simple-resource/mcp_simple_resource/server.py +++ b/examples/servers/simple-resource/mcp_simple_resource/server.py @@ -32,7 +32,9 @@ def main(port: int, transport: str) -> int: app = Server("mcp-simple-resource") @app.list_resources() - async def list_resources() -> list[types.Resource]: + async def list_resources( + request: types.ListResourcesRequest, + ) -> list[types.Resource]: return [ types.Resource( uri=FileUrl(f"file:///{name}.txt"), diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 1853ce7c1..0c69436bf 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -221,25 +221,35 @@ async def set_logging_level(self, level: types.LoggingLevel) -> types.EmptyResul types.EmptyResult, ) - async def list_resources(self, cursor: str | None = None) -> types.ListResourcesResult: + async def list_resources(self, prefix: str | None = None, cursor: str | None = None) -> types.ListResourcesResult: """Send a resources/list request.""" + params = None + if cursor is not None or prefix is not None: + params = types.ListResourcesRequestParams(prefix=prefix, cursor=cursor) return await self.send_request( types.ClientRequest( types.ListResourcesRequest( method="resources/list", - params=types.PaginatedRequestParams(cursor=cursor) if cursor is not None else None, + params=params, ) ), types.ListResourcesResult, ) - async def list_resource_templates(self, cursor: str | None = None) -> types.ListResourceTemplatesResult: + async def list_resource_templates( + self, + prefix: str | None = None, + cursor: str | None = None, + ) -> types.ListResourceTemplatesResult: """Send a resources/templates/list request.""" + params = None + if cursor is not None or prefix is not None: + params = types.ListResourceTemplatesRequestParams(prefix=prefix, cursor=cursor) return await self.send_request( types.ClientRequest( types.ListResourceTemplatesRequest( method="resources/templates/list", - params=types.PaginatedRequestParams(cursor=cursor) if cursor is not None else None, + params=params, ) ), types.ListResourceTemplatesResult, diff --git a/src/mcp/server/fastmcp/resources/resource_manager.py b/src/mcp/server/fastmcp/resources/resource_manager.py index 35e4ec04d..7cc62b034 100644 --- a/src/mcp/server/fastmcp/resources/resource_manager.py +++ b/src/mcp/server/fastmcp/resources/resource_manager.py @@ -86,12 +86,18 @@ async def get_resource(self, uri: AnyUrl | str) -> Resource | None: raise ValueError(f"Unknown resource: {uri}") - def list_resources(self) -> list[Resource]: - """List all registered resources.""" - logger.debug("Listing resources", extra={"count": len(self._resources)}) - return list(self._resources.values()) - - def list_templates(self) -> list[ResourceTemplate]: - """List all registered templates.""" - logger.debug("Listing templates", extra={"count": len(self._templates)}) - return list(self._templates.values()) + def list_resources(self, prefix: str | None = None) -> list[Resource]: + """List all registered resources, optionally filtered by URI prefix.""" + resources = list(self._resources.values()) + if prefix: + resources = [r for r in resources if str(r.uri).startswith(prefix)] + logger.debug("Listing resources", extra={"count": len(resources), "prefix": prefix}) + return resources + + def list_templates(self, prefix: str | None = None) -> list[ResourceTemplate]: + """List all registered templates, optionally filtered by URI template prefix.""" + templates = list(self._templates.values()) + if prefix: + templates = [t for t in templates if t.matches_prefix(prefix)] + logger.debug("Listing templates", extra={"count": len(templates), "prefix": prefix}) + return templates diff --git a/src/mcp/server/fastmcp/resources/templates.py b/src/mcp/server/fastmcp/resources/templates.py index b1c7b2711..54b04dde3 100644 --- a/src/mcp/server/fastmcp/resources/templates.py +++ b/src/mcp/server/fastmcp/resources/templates.py @@ -63,6 +63,44 @@ def matches(self, uri: str) -> dict[str, Any] | None: return match.groupdict() return None + def matches_prefix(self, prefix: str) -> bool: + """Check if this template could match URIs with the given prefix.""" + + # First, simple check: does the template itself start with the prefix? + if self.uri_template.startswith(prefix): + return True + + template_segments = self.uri_template.split("/") + prefix_segments = prefix.split("/") + + # Handle trailing slash - it creates an empty last segment + has_trailing_slash = prefix.endswith("/") and prefix_segments[-1] == "" + if has_trailing_slash: + # Remove the empty segment for comparison + prefix_segments = prefix_segments[:-1] + # Template must have more segments to generate something "under" this path + if len(template_segments) <= len(prefix_segments): + return False + else: + # Without trailing slash, prefix can't have more segments than template + if len(prefix_segments) > len(template_segments): + return False + + # Compare each segment + for i, prefix_seg in enumerate(prefix_segments): + template_seg = template_segments[i] + + # If template segment is a parameter, it can match any value + if template_seg.startswith("{") and template_seg.endswith("}"): + continue + + # If both are literals, they must match exactly + if template_seg != prefix_seg: + return False + + # All prefix segments matched + return True + async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: """Create a resource from the template with the given parameters.""" try: diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 924baaa9b..aba2bcf0c 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -21,6 +21,7 @@ from starlette.routing import Mount, Route from starlette.types import Receive, Scope, Send +from mcp import types from mcp.server.auth.middleware.auth_context import AuthContextMiddleware from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier @@ -297,10 +298,12 @@ async def call_tool(self, name: str, arguments: dict[str, Any]) -> Sequence[Cont context = self.get_context() return await self._tool_manager.call_tool(name, arguments, context=context, convert_result=True) - async def list_resources(self) -> list[MCPResource]: - """List all available resources.""" - - resources = self._resource_manager.list_resources() + async def list_resources(self, request: types.ListResourcesRequest | None = None) -> list[MCPResource]: + """List all available resources, optionally filtered by prefix.""" + prefix = None + if request and request.params: + prefix = request.params.prefix + resources = self._resource_manager.list_resources(prefix=prefix) return [ MCPResource( uri=resource.uri, @@ -312,8 +315,14 @@ async def list_resources(self) -> list[MCPResource]: for resource in resources ] - async def list_resource_templates(self) -> list[MCPResourceTemplate]: - templates = self._resource_manager.list_templates() + async def list_resource_templates( + self, request: types.ListResourceTemplatesRequest | None = None + ) -> list[MCPResourceTemplate]: + """List all available resource templates, optionally filtered by prefix.""" + prefix = None + if request and request.params: + prefix = request.params.prefix + templates = self._resource_manager.list_templates(prefix=prefix) return [ MCPResourceTemplate( uriTemplate=template.uri_template, diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index ab6a3d15c..0100cdb50 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -259,11 +259,11 @@ async def handler(req: types.GetPromptRequest): return decorator def list_resources(self): - def decorator(func: Callable[[], Awaitable[list[types.Resource]]]): + def decorator(func: Callable[[types.ListResourcesRequest], Awaitable[list[types.Resource]]]): logger.debug("Registering handler for ListResourcesRequest") - async def handler(_: Any): - resources = await func() + async def handler(request: types.ListResourcesRequest): + resources = await func(request) return types.ServerResult(types.ListResourcesResult(resources=resources)) self.request_handlers[types.ListResourcesRequest] = handler @@ -272,11 +272,11 @@ async def handler(_: Any): return decorator def list_resource_templates(self): - def decorator(func: Callable[[], Awaitable[list[types.ResourceTemplate]]]): + def decorator(func: Callable[[types.ListResourceTemplatesRequest], Awaitable[list[types.ResourceTemplate]]]): logger.debug("Registering handler for ListResourceTemplatesRequest") - async def handler(_: Any): - templates = await func() + async def handler(request: types.ListResourceTemplatesRequest): + templates = await func(request) return types.ServerResult(types.ListResourceTemplatesResult(resourceTemplates=templates)) self.request_handlers[types.ListResourceTemplatesRequest] = handler diff --git a/src/mcp/types.py b/src/mcp/types.py index 98fefa080..078bbc871 100644 --- a/src/mcp/types.py +++ b/src/mcp/types.py @@ -63,6 +63,20 @@ class PaginatedRequestParams(RequestParams): """ +class ListResourcesRequestParams(PaginatedRequestParams): + """Parameters for listing resources with optional prefix filtering.""" + + prefix: str | None = None + """Optional prefix to filter resources by URI.""" + + +class ListResourceTemplatesRequestParams(PaginatedRequestParams): + """Parameters for listing resource templates with optional prefix filtering.""" + + prefix: str | None = None + """Optional prefix to filter resource templates by URI template.""" + + class NotificationParams(BaseModel): class Meta(BaseModel): model_config = ConfigDict(extra="allow") @@ -394,10 +408,11 @@ class ProgressNotification(Notification[ProgressNotificationParams, Literal["not params: ProgressNotificationParams -class ListResourcesRequest(PaginatedRequest[Literal["resources/list"]]): +class ListResourcesRequest(Request[ListResourcesRequestParams | None, Literal["resources/list"]]): """Sent from the client to request a list of resources the server has.""" method: Literal["resources/list"] + params: ListResourcesRequestParams | None = None class Annotations(BaseModel): @@ -461,10 +476,13 @@ class ListResourcesResult(PaginatedResult): resources: list[Resource] -class ListResourceTemplatesRequest(PaginatedRequest[Literal["resources/templates/list"]]): +class ListResourceTemplatesRequest( + Request[ListResourceTemplatesRequestParams | None, Literal["resources/templates/list"]] +): """Sent from the client to request a list of resource templates the server has.""" method: Literal["resources/templates/list"] + params: ListResourceTemplatesRequestParams | None = None class ListResourceTemplatesResult(PaginatedResult): diff --git a/tests/issues/test_152_resource_mime_type.py b/tests/issues/test_152_resource_mime_type.py index a99e5a5c7..6e9036174 100644 --- a/tests/issues/test_152_resource_mime_type.py +++ b/tests/issues/test_152_resource_mime_type.py @@ -79,7 +79,7 @@ async def test_lowlevel_resource_mime_type(): ] @server.list_resources() - async def handle_list_resources(): + async def handle_list_resources(request: types.ListResourcesRequest): return test_resources @server.read_resource() diff --git a/tests/server/fastmcp/resources/test_resource_manager.py b/tests/server/fastmcp/resources/test_resource_manager.py index 4423e5315..689b1f6ae 100644 --- a/tests/server/fastmcp/resources/test_resource_manager.py +++ b/tests/server/fastmcp/resources/test_resource_manager.py @@ -139,3 +139,97 @@ def test_list_resources(self, temp_file: Path): resources = manager.list_resources() assert len(resources) == 2 assert resources == [resource1, resource2] + + def test_list_resources_with_prefix(self, temp_file: Path): + """Test listing resources with prefix filtering.""" + manager = ResourceManager() + + # Add resources with different URIs + resource1 = FileResource( + uri=FileUrl("file:///data/images/test.jpg"), + name="test_image", + path=temp_file, + ) + resource2 = FileResource( + uri=FileUrl("file:///data/docs/test.txt"), + name="test_doc", + path=temp_file, + ) + resource3 = FileResource( + uri=FileUrl("file:///other/test.txt"), + name="other_test", + path=temp_file, + ) + + manager.add_resource(resource1) + manager.add_resource(resource2) + manager.add_resource(resource3) + + # Test prefix filtering + data_resources = manager.list_resources(prefix="file:///data/") + assert len(data_resources) == 2 + assert resource1 in data_resources + assert resource2 in data_resources + + # More specific prefix + image_resources = manager.list_resources(prefix="file:///data/images/") + assert len(image_resources) == 1 + assert resource1 in image_resources + + # No matches + no_matches = manager.list_resources(prefix="file:///nonexistent/") + assert len(no_matches) == 0 + + def test_list_templates_with_prefix(self): + """Test listing templates with prefix filtering.""" + manager = ResourceManager() + + # Add templates with different URI patterns + def user_func(user_id: str) -> str: + return f"User {user_id}" + + def post_func(user_id: str, post_id: str) -> str: + return f"User {user_id} Post {post_id}" + + def product_func(product_id: str) -> str: + return f"Product {product_id}" + + template1 = manager.add_template(user_func, uri_template="http://api.com/users/{user_id}", name="user_template") + template2 = manager.add_template( + post_func, uri_template="http://api.com/users/{user_id}/posts/{post_id}", name="post_template" + ) + template3 = manager.add_template( + product_func, uri_template="http://api.com/products/{product_id}", name="product_template" + ) + + # Test listing all templates + all_templates = manager.list_templates() + assert len(all_templates) == 3 + + # Test prefix filtering - matches both user templates + user_templates = manager.list_templates(prefix="http://api.com/users/") + assert len(user_templates) == 2 + assert template1 in user_templates + assert template2 in user_templates + + # Test partial materialization - only matches post template + # The template users/{user_id} generates "users/123" not "users/123/" + # But users/{user_id}/posts/{post_id} can generate "users/123/posts/456" + user_123_templates = manager.list_templates(prefix="http://api.com/users/123/") + assert len(user_123_templates) == 1 + assert template2 in user_123_templates # users/{user_id}/posts/{post_id} matches + + # Without trailing slash, both match + user_123_no_slash = manager.list_templates(prefix="http://api.com/users/123") + assert len(user_123_no_slash) == 2 + assert template1 in user_123_no_slash + assert template2 in user_123_no_slash + + # Test product prefix + product_templates = manager.list_templates(prefix="http://api.com/products/") + assert len(product_templates) == 1 + assert template3 in product_templates + + # No matches + no_matches = manager.list_templates(prefix="http://api.com/orders/") + assert len(no_matches) == 0 diff --git a/tests/server/fastmcp/resources/test_resource_template.py b/tests/server/fastmcp/resources/test_resource_template.py index f47244361..bcf040813 100644 --- a/tests/server/fastmcp/resources/test_resource_template.py +++ b/tests/server/fastmcp/resources/test_resource_template.py @@ -186,3 +186,117 @@ def get_data(value: str) -> CustomData: assert isinstance(resource, FunctionResource) content = await resource.read() assert content == '"hello"' + + def test_matches_prefix_exact_template(self): + """Test that templates match when prefix matches template exactly.""" + + def dummy_func() -> str: + return "data" + + template = ResourceTemplate.from_function( + dummy_func, uri_template="http://api.example.com/users/{user_id}", name="test" + ) + + # Exact prefix of template + assert template.matches_prefix("http://api.example.com/users/") + assert template.matches_prefix("http://api.example.com/users") + assert template.matches_prefix("http://api.example.com/") + assert template.matches_prefix("http://") + + def test_matches_prefix_partial_materialization(self): + """Test matching with partially materialized parameters.""" + + def dummy_func(user_id: str, post_id: str) -> str: + return f"User {user_id} Post {post_id}" + + template = ResourceTemplate.from_function( + dummy_func, uri_template="http://api.example.com/users/{user_id}/posts/{post_id}", name="test" + ) + + # Partial materialization - user_id replaced with value + assert template.matches_prefix("http://api.example.com/users/123/") + assert template.matches_prefix("http://api.example.com/users/123/posts/") + assert template.matches_prefix("http://api.example.com/users/alice/posts/") + + # Without trailing slash + assert template.matches_prefix("http://api.example.com/users/123") + assert template.matches_prefix("http://api.example.com/users/123/posts") + + def test_matches_prefix_no_match_different_structure(self): + """Test that templates don't match when structure differs.""" + + def dummy_func(user_id: str) -> str: + return f"User {user_id}" + + template = ResourceTemplate.from_function( + dummy_func, uri_template="http://api.example.com/users/{user_id}", name="test" + ) + + # Different path structure + assert not template.matches_prefix("http://api.example.com/products/") + assert not template.matches_prefix("http://api.example.com/users/123/invalid/") + assert not template.matches_prefix("http://different.com/users/") + + def test_matches_prefix_complex_nested(self): + """Test matching with complex nested templates.""" + + def dummy_func(org_id: str, team_id: str, user_id: str) -> str: + return f"Org {org_id} Team {team_id} User {user_id}" + + template = ResourceTemplate.from_function( + dummy_func, uri_template="http://api.example.com/orgs/{org_id}/teams/{team_id}/users/{user_id}", name="test" + ) + + # Various levels of partial materialization + assert template.matches_prefix("http://api.example.com/orgs/") + assert template.matches_prefix("http://api.example.com/orgs/acme/") + assert template.matches_prefix("http://api.example.com/orgs/acme/teams/") + assert template.matches_prefix("http://api.example.com/orgs/acme/teams/dev/") + assert template.matches_prefix("http://api.example.com/orgs/acme/teams/dev/users/") + + def test_matches_prefix_file_uri(self): + """Test matching with file:// URI templates.""" + + def dummy_func(category: str, filename: str) -> str: + return f"File {category}/{filename}" + + template = ResourceTemplate.from_function( + dummy_func, uri_template="file:///data/{category}/{filename}", name="test" + ) + + assert template.matches_prefix("file:///data/") + assert template.matches_prefix("file:///data/images/") + assert template.matches_prefix("file:///data/docs/") + assert not template.matches_prefix("file:///other/") + + def test_matches_prefix_trailing_slash_semantics(self): + """Test that trailing slashes have semantic meaning.""" + + def dummy_func(id: str) -> str: + return f"Item {id}" + + template = ResourceTemplate.from_function( + dummy_func, uri_template="http://api.example.com/items/{id}", name="test" + ) + + # Prefix without trailing slash matches (looking for items or under items) + assert template.matches_prefix("http://api.example.com/items") + assert template.matches_prefix("http://api.example.com/items/123") + + # Prefix with trailing slash only matches if template generates something under it + assert template.matches_prefix("http://api.example.com/items/") # template generates items/X + assert not template.matches_prefix("http://api.example.com/items/123/") # template can't generate items/123/... + + def test_matches_prefix_longer_than_template(self): + """Test that prefixes longer than template don't match.""" + + def dummy_func(id: str) -> str: + return f"Item {id}" + + template = ResourceTemplate.from_function( + dummy_func, uri_template="http://api.example.com/items/{id}", name="test" + ) + + # Prefix has more segments than template + assert not template.matches_prefix("http://api.example.com/items/123/extra/") + assert not template.matches_prefix("http://api.example.com/items/123/extra/more/") diff --git a/tests/server/test_session.py b/tests/server/test_session.py index 154c3a368..e10a51b4d 100644 --- a/tests/server/test_session.py +++ b/tests/server/test_session.py @@ -100,7 +100,7 @@ async def list_prompts(): # Add a resources handler @server.list_resources() - async def list_resources(): + async def list_resources(request: types.ListResourcesRequest): return [] caps = server.get_capabilities(notification_options, experimental_capabilities) diff --git a/tests/shared/test_memory.py b/tests/shared/test_memory.py index a0c32f556..b1e0c5c73 100644 --- a/tests/shared/test_memory.py +++ b/tests/shared/test_memory.py @@ -9,6 +9,7 @@ ) from mcp.types import ( EmptyResult, + ListResourcesRequest, Resource, ) @@ -18,7 +19,7 @@ def mcp_server() -> Server: server = Server(name="test_server") @server.list_resources() - async def handle_list_resources(): + async def handle_list_resources(request: ListResourcesRequest): return [ Resource( uri=AnyUrl("memory://test"),