Skip to content

Commit 7453e70

Browse files
author
Tapan Chugh
committed
Implement prefix based matching for list resources / templates methods
1 parent 959d4e3 commit 7453e70

File tree

12 files changed

+323
-31
lines changed

12 files changed

+323
-31
lines changed

examples/servers/simple-resource/mcp_simple_resource/server.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ def main(port: int, transport: str) -> int:
3232
app = Server("mcp-simple-resource")
3333

3434
@app.list_resources()
35-
async def list_resources() -> list[types.Resource]:
35+
async def list_resources(
36+
request: types.ListResourcesRequest,
37+
) -> list[types.Resource]:
3638
return [
3739
types.Resource(
3840
uri=FileUrl(f"file:///{name}.txt"),

src/mcp/client/session.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -221,25 +221,35 @@ async def set_logging_level(self, level: types.LoggingLevel) -> types.EmptyResul
221221
types.EmptyResult,
222222
)
223223

224-
async def list_resources(self, cursor: str | None = None) -> types.ListResourcesResult:
224+
async def list_resources(self, prefix: str | None = None, cursor: str | None = None) -> types.ListResourcesResult:
225225
"""Send a resources/list request."""
226+
params = None
227+
if cursor is not None or prefix is not None:
228+
params = types.ListResourcesRequestParams(prefix=prefix, cursor=cursor)
226229
return await self.send_request(
227230
types.ClientRequest(
228231
types.ListResourcesRequest(
229232
method="resources/list",
230-
params=types.PaginatedRequestParams(cursor=cursor) if cursor is not None else None,
233+
params=params,
231234
)
232235
),
233236
types.ListResourcesResult,
234237
)
235238

236-
async def list_resource_templates(self, cursor: str | None = None) -> types.ListResourceTemplatesResult:
239+
async def list_resource_templates(
240+
self,
241+
prefix: str | None = None,
242+
cursor: str | None = None,
243+
) -> types.ListResourceTemplatesResult:
237244
"""Send a resources/templates/list request."""
245+
params = None
246+
if cursor is not None or prefix is not None:
247+
params = types.ListResourceTemplatesRequestParams(prefix=prefix, cursor=cursor)
238248
return await self.send_request(
239249
types.ClientRequest(
240250
types.ListResourceTemplatesRequest(
241251
method="resources/templates/list",
242-
params=types.PaginatedRequestParams(cursor=cursor) if cursor is not None else None,
252+
params=params,
243253
)
244254
),
245255
types.ListResourceTemplatesResult,

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

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,18 @@ async def get_resource(self, uri: AnyUrl | str) -> Resource | None:
8686

8787
raise ValueError(f"Unknown resource: {uri}")
8888

89-
def list_resources(self) -> list[Resource]:
90-
"""List all registered resources."""
91-
logger.debug("Listing resources", extra={"count": len(self._resources)})
92-
return list(self._resources.values())
93-
94-
def list_templates(self) -> list[ResourceTemplate]:
95-
"""List all registered templates."""
96-
logger.debug("Listing templates", extra={"count": len(self._templates)})
97-
return list(self._templates.values())
89+
def list_resources(self, prefix: str | None = None) -> list[Resource]:
90+
"""List all registered resources, optionally filtered by URI prefix."""
91+
resources = list(self._resources.values())
92+
if prefix:
93+
resources = [r for r in resources if str(r.uri).startswith(prefix)]
94+
logger.debug("Listing resources", extra={"count": len(resources), "prefix": prefix})
95+
return resources
96+
97+
def list_templates(self, prefix: str | None = None) -> list[ResourceTemplate]:
98+
"""List all registered templates, optionally filtered by URI template prefix."""
99+
templates = list(self._templates.values())
100+
if prefix:
101+
templates = [t for t in templates if t.matches_prefix(prefix)]
102+
logger.debug("Listing templates", extra={"count": len(templates), "prefix": prefix})
103+
return templates

src/mcp/server/fastmcp/resources/templates.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,44 @@ def matches(self, uri: str) -> dict[str, Any] | None:
6363
return match.groupdict()
6464
return None
6565

66+
def matches_prefix(self, prefix: str) -> bool:
67+
"""Check if this template could match URIs with the given prefix."""
68+
69+
# First, simple check: does the template itself start with the prefix?
70+
if self.uri_template.startswith(prefix):
71+
return True
72+
73+
template_segments = self.uri_template.split("/")
74+
prefix_segments = prefix.split("/")
75+
76+
# Handle trailing slash - it creates an empty last segment
77+
has_trailing_slash = prefix.endswith("/") and prefix_segments[-1] == ""
78+
if has_trailing_slash:
79+
# Remove the empty segment for comparison
80+
prefix_segments = prefix_segments[:-1]
81+
# Template must have more segments to generate something "under" this path
82+
if len(template_segments) <= len(prefix_segments):
83+
return False
84+
else:
85+
# Without trailing slash, prefix can't have more segments than template
86+
if len(prefix_segments) > len(template_segments):
87+
return False
88+
89+
# Compare each segment
90+
for i, prefix_seg in enumerate(prefix_segments):
91+
template_seg = template_segments[i]
92+
93+
# If template segment is a parameter, it can match any value
94+
if template_seg.startswith("{") and template_seg.endswith("}"):
95+
continue
96+
97+
# If both are literals, they must match exactly
98+
if template_seg != prefix_seg:
99+
return False
100+
101+
# All prefix segments matched
102+
return True
103+
66104
async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource:
67105
"""Create a resource from the template with the given parameters."""
68106
try:

src/mcp/server/fastmcp/server.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from starlette.routing import Mount, Route
2222
from starlette.types import Receive, Scope, Send
2323

24+
from mcp import types
2425
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
2526
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
2627
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
297298
context = self.get_context()
298299
return await self._tool_manager.call_tool(name, arguments, context=context, convert_result=True)
299300

300-
async def list_resources(self) -> list[MCPResource]:
301-
"""List all available resources."""
302-
303-
resources = self._resource_manager.list_resources()
301+
async def list_resources(self, request: types.ListResourcesRequest | None = None) -> list[MCPResource]:
302+
"""List all available resources, optionally filtered by prefix."""
303+
prefix = None
304+
if request and request.params:
305+
prefix = request.params.prefix
306+
resources = self._resource_manager.list_resources(prefix=prefix)
304307
return [
305308
MCPResource(
306309
uri=resource.uri,
@@ -312,8 +315,14 @@ async def list_resources(self) -> list[MCPResource]:
312315
for resource in resources
313316
]
314317

315-
async def list_resource_templates(self) -> list[MCPResourceTemplate]:
316-
templates = self._resource_manager.list_templates()
318+
async def list_resource_templates(
319+
self, request: types.ListResourceTemplatesRequest | None = None
320+
) -> list[MCPResourceTemplate]:
321+
"""List all available resource templates, optionally filtered by prefix."""
322+
prefix = None
323+
if request and request.params:
324+
prefix = request.params.prefix
325+
templates = self._resource_manager.list_templates(prefix=prefix)
317326
return [
318327
MCPResourceTemplate(
319328
uriTemplate=template.uri_template,

src/mcp/server/lowlevel/server.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -259,11 +259,11 @@ async def handler(req: types.GetPromptRequest):
259259
return decorator
260260

261261
def list_resources(self):
262-
def decorator(func: Callable[[], Awaitable[list[types.Resource]]]):
262+
def decorator(func: Callable[[types.ListResourcesRequest], Awaitable[list[types.Resource]]]):
263263
logger.debug("Registering handler for ListResourcesRequest")
264264

265-
async def handler(_: Any):
266-
resources = await func()
265+
async def handler(request: types.ListResourcesRequest):
266+
resources = await func(request)
267267
return types.ServerResult(types.ListResourcesResult(resources=resources))
268268

269269
self.request_handlers[types.ListResourcesRequest] = handler
@@ -272,11 +272,11 @@ async def handler(_: Any):
272272
return decorator
273273

274274
def list_resource_templates(self):
275-
def decorator(func: Callable[[], Awaitable[list[types.ResourceTemplate]]]):
275+
def decorator(func: Callable[[types.ListResourceTemplatesRequest], Awaitable[list[types.ResourceTemplate]]]):
276276
logger.debug("Registering handler for ListResourceTemplatesRequest")
277277

278-
async def handler(_: Any):
279-
templates = await func()
278+
async def handler(request: types.ListResourceTemplatesRequest):
279+
templates = await func(request)
280280
return types.ServerResult(types.ListResourceTemplatesResult(resourceTemplates=templates))
281281

282282
self.request_handlers[types.ListResourceTemplatesRequest] = handler

src/mcp/types.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,20 @@ class PaginatedRequestParams(RequestParams):
6363
"""
6464

6565

66+
class ListResourcesRequestParams(PaginatedRequestParams):
67+
"""Parameters for listing resources with optional prefix filtering."""
68+
69+
prefix: str | None = None
70+
"""Optional prefix to filter resources by URI."""
71+
72+
73+
class ListResourceTemplatesRequestParams(PaginatedRequestParams):
74+
"""Parameters for listing resource templates with optional prefix filtering."""
75+
76+
prefix: str | None = None
77+
"""Optional prefix to filter resource templates by URI template."""
78+
79+
6680
class NotificationParams(BaseModel):
6781
class Meta(BaseModel):
6882
model_config = ConfigDict(extra="allow")
@@ -394,10 +408,11 @@ class ProgressNotification(Notification[ProgressNotificationParams, Literal["not
394408
params: ProgressNotificationParams
395409

396410

397-
class ListResourcesRequest(PaginatedRequest[Literal["resources/list"]]):
411+
class ListResourcesRequest(Request[ListResourcesRequestParams | None, Literal["resources/list"]]):
398412
"""Sent from the client to request a list of resources the server has."""
399413

400414
method: Literal["resources/list"]
415+
params: ListResourcesRequestParams | None = None
401416

402417

403418
class Annotations(BaseModel):
@@ -461,10 +476,13 @@ class ListResourcesResult(PaginatedResult):
461476
resources: list[Resource]
462477

463478

464-
class ListResourceTemplatesRequest(PaginatedRequest[Literal["resources/templates/list"]]):
479+
class ListResourceTemplatesRequest(
480+
Request[ListResourceTemplatesRequestParams | None, Literal["resources/templates/list"]]
481+
):
465482
"""Sent from the client to request a list of resource templates the server has."""
466483

467484
method: Literal["resources/templates/list"]
485+
params: ListResourceTemplatesRequestParams | None = None
468486

469487

470488
class ListResourceTemplatesResult(PaginatedResult):

tests/issues/test_152_resource_mime_type.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ async def test_lowlevel_resource_mime_type():
7979
]
8080

8181
@server.list_resources()
82-
async def handle_list_resources():
82+
async def handle_list_resources(request: types.ListResourcesRequest):
8383
return test_resources
8484

8585
@server.read_resource()

tests/server/fastmcp/resources/test_resource_manager.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,97 @@ def test_list_resources(self, temp_file: Path):
139139
resources = manager.list_resources()
140140
assert len(resources) == 2
141141
assert resources == [resource1, resource2]
142+
143+
def test_list_resources_with_prefix(self, temp_file: Path):
144+
"""Test listing resources with prefix filtering."""
145+
manager = ResourceManager()
146+
147+
# Add resources with different URIs
148+
resource1 = FileResource(
149+
uri=FileUrl("file:///data/images/test.jpg"),
150+
name="test_image",
151+
path=temp_file,
152+
)
153+
resource2 = FileResource(
154+
uri=FileUrl("file:///data/docs/test.txt"),
155+
name="test_doc",
156+
path=temp_file,
157+
)
158+
resource3 = FileResource(
159+
uri=FileUrl("file:///other/test.txt"),
160+
name="other_test",
161+
path=temp_file,
162+
)
163+
164+
manager.add_resource(resource1)
165+
manager.add_resource(resource2)
166+
manager.add_resource(resource3)
167+
168+
# Test prefix filtering
169+
data_resources = manager.list_resources(prefix="file:///data/")
170+
assert len(data_resources) == 2
171+
assert resource1 in data_resources
172+
assert resource2 in data_resources
173+
174+
# More specific prefix
175+
image_resources = manager.list_resources(prefix="file:///data/images/")
176+
assert len(image_resources) == 1
177+
assert resource1 in image_resources
178+
179+
# No matches
180+
no_matches = manager.list_resources(prefix="file:///nonexistent/")
181+
assert len(no_matches) == 0
182+
183+
def test_list_templates_with_prefix(self):
184+
"""Test listing templates with prefix filtering."""
185+
manager = ResourceManager()
186+
187+
# Add templates with different URI patterns
188+
def user_func(user_id: str) -> str:
189+
return f"User {user_id}"
190+
191+
def post_func(user_id: str, post_id: str) -> str:
192+
return f"User {user_id} Post {post_id}"
193+
194+
def product_func(product_id: str) -> str:
195+
return f"Product {product_id}"
196+
197+
template1 = manager.add_template(user_func, uri_template="http://api.com/users/{user_id}", name="user_template")
198+
template2 = manager.add_template(
199+
post_func, uri_template="http://api.com/users/{user_id}/posts/{post_id}", name="post_template"
200+
)
201+
template3 = manager.add_template(
202+
product_func, uri_template="http://api.com/products/{product_id}", name="product_template"
203+
)
204+
205+
# Test listing all templates
206+
all_templates = manager.list_templates()
207+
assert len(all_templates) == 3
208+
209+
# Test prefix filtering - matches both user templates
210+
user_templates = manager.list_templates(prefix="http://api.com/users/")
211+
assert len(user_templates) == 2
212+
assert template1 in user_templates
213+
assert template2 in user_templates
214+
215+
# Test partial materialization - only matches post template
216+
# The template users/{user_id} generates "users/123" not "users/123/"
217+
# But users/{user_id}/posts/{post_id} can generate "users/123/posts/456"
218+
user_123_templates = manager.list_templates(prefix="http://api.com/users/123/")
219+
assert len(user_123_templates) == 1
220+
assert template2 in user_123_templates # users/{user_id}/posts/{post_id} matches
221+
222+
# Without trailing slash, both match
223+
user_123_no_slash = manager.list_templates(prefix="http://api.com/users/123")
224+
assert len(user_123_no_slash) == 2
225+
assert template1 in user_123_no_slash
226+
assert template2 in user_123_no_slash
227+
228+
# Test product prefix
229+
product_templates = manager.list_templates(prefix="http://api.com/products/")
230+
assert len(product_templates) == 1
231+
assert template3 in product_templates
232+
233+
# No matches
234+
no_matches = manager.list_templates(prefix="http://api.com/orders/")
235+
assert len(no_matches) == 0

0 commit comments

Comments
 (0)