From 713864aa9a8cdb39295262bce73db0acd2b79447 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 25 Apr 2025 14:25:17 -0400 Subject: [PATCH] Ensure servers expose template wildcards --- src/fastmcp/resources/template.py | 2 +- tests/resources/test_resource_template.py | 60 +++++++++++++++++++ tests/server/test_server.py | 73 +++++++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index d174365f3..0d804f3da 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -95,7 +95,7 @@ class ResourceTemplate(BaseModel): raise ValueError("You must provide a name for lambda functions") # Validate that URI params match function params - uri_params = set(re.findall(r"{(\w+)}", uri_template)) + uri_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template)) if not uri_params: raise ValueError("URI template must contain at least one parameter") diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 33db36cd4..86fd24df4 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -297,6 +297,66 @@ class TestResourceTemplate: content = await resource.read() assert content == "hello" + async def test_wildcard_param_can_create_resource(self): + """Test that wildcard parameters are valid.""" + + def identity(path: str) -> str: + return path + + template = ResourceTemplate.from_function( + fn=identity, + uri_template="test://{path*}.py", + name="test", + ) + + assert await template.create_resource( + "test://path/to/test.py", + {"path": "path/to/test.py"}, + ) + + async def test_wildcard_param_matches(self): + def identify(path: str) -> str: + return path + + template = ResourceTemplate.from_function( + fn=identify, + uri_template="test://src/{path*}.py", + name="test", + ) + # Valid match + params = template.matches("test://src/path/to/test.py") + assert params == {"path": "path/to/test"} + + async def test_multiple_wildcard_params(self): + """Test that multiple wildcard parameters are valid.""" + + def identity(path: str, path2: str) -> str: + return f"{path}/{path2}" + + template = ResourceTemplate.from_function( + fn=identity, + uri_template="test://{path*}/xyz/{path2*}", + name="test", + ) + + params = template.matches("test://path/to/xyz/abc") + assert params == {"path": "path/to", "path2": "abc"} + + async def test_wildcard_param_with_regular_param(self): + """Test that a wildcard parameter can be used with a regular parameter.""" + + def identity(prefix: str, path: str) -> str: + return f"{prefix}/{path}" + + template = ResourceTemplate.from_function( + fn=identity, + uri_template="test://{prefix}/{path*}", + name="test", + ) + + params = template.matches("test://src/path/to/test.py") + assert params == {"prefix": "src", "path": "path/to/test.py"} + class TestMatchUriTemplate: """Test match_uri_template function.""" diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 42a47ae65..f27e0c042 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -531,6 +531,18 @@ class TestTemplateDecorator: template = templates_dict["resource://{param}"] assert template.tags == {"template", "test-tag"} + async def test_template_decorator_wildcard_param(self): + mcp = FastMCP() + + @mcp.resource("resource://{param*}") + def template_resource(param: str) -> str: + return f"Template resource: {param}" + + templates_dict = await mcp.get_resource_templates() + template = templates_dict["resource://{param*}"] + assert template.uri_template == "resource://{param*}" + assert template.name == "template_resource" + class TestPromptDecorator: async def test_prompt_decorator(self): @@ -1143,6 +1155,67 @@ class TestServerResourceTemplates: template = templates_dict["resource://{param}"] assert template.tags == {"template", "test-tag"} + async def test_template_decorator_wildcard_param(self): + mcp = FastMCP() + + @mcp.resource("resource://{param*}") + def template_resource(param: str) -> str: + return f"Template resource: {param}" + + async with Client(mcp) as client: + result = await client.read_resource(AnyUrl("resource://test/data")) + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Template resource: test/data" + + async def test_templates_match_in_order_of_definition(self): + """ + If a wildcard template is defined first, it will take priority over another + matching template. + + """ + mcp = FastMCP() + + @mcp.resource("resource://{param*}") + def template_resource(param: str) -> str: + return f"Template resource 1: {param}" + + @mcp.resource("resource://{x}/{y}") + def template_resource_with_params(x: str, y: str) -> str: + return f"Template resource 2: {x}/{y}" + + async with Client(mcp) as client: + result = await client.read_resource(AnyUrl("resource://a/b/c")) + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Template resource 1: a/b/c" + + result = await client.read_resource(AnyUrl("resource://a/b")) + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Template resource 1: a/b" + + async def test_templates_shadow_each_other_reorder(self): + """ + If a wildcard template is defined second, it will *not* take priority over + another matching template. + """ + mcp = FastMCP() + + @mcp.resource("resource://{x}/{y}") + def template_resource_with_params(x: str, y: str) -> str: + return f"Template resource 1: {x}/{y}" + + @mcp.resource("resource://{param*}") + def template_resource(param: str) -> str: + return f"Template resource 2: {param}" + + async with Client(mcp) as client: + result = await client.read_resource(AnyUrl("resource://a/b/c")) + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Template resource 2: a/b/c" + + result = await client.read_resource(AnyUrl("resource://a/b")) + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Template resource 1: a/b" + class TestContextInjection: """Test context injection in tools."""