diff --git a/fastmcp_slim/fastmcp/resources/template.py b/fastmcp_slim/fastmcp/resources/template.py index 919c43f8c..972cb9f4e 100644 --- a/fastmcp_slim/fastmcp/resources/template.py +++ b/fastmcp_slim/fastmcp/resources/template.py @@ -125,17 +125,25 @@ def expand_uri_template(uri_template: str, params: dict[str, Any]) -> str: """ result = uri_template - # Replace {name} and {name*} path placeholders. + # Replace {name} and {name*} path placeholders, percent-encoding the + # substituted values so the result round-trips through match_uri_template + # (which unquotes captured groups). Simple {name} placeholders match a + # single segment ([^/]+), so reserved characters including "/" are encoded; + # wildcard {name*} placeholders may span segments, so "/" is preserved. + # # Params use underscored keys (e.g. user_id) but templates may use # hyphens (e.g. {user-id}), so try both forms. for key, value in params.items(): value_str = str(value) - result = result.replace(f"{{{key}}}", value_str) - result = result.replace(f"{{{key}*}}", value_str) + simple = quote(value_str, safe="") + wildcard = quote(value_str, safe="/") + forms = [key] hyphenated = key.replace("_", "-") if hyphenated != key: - result = result.replace(f"{{{hyphenated}}}", value_str) - result = result.replace(f"{{{hyphenated}*}}", value_str) + forms.append(hyphenated) + for form in forms: + result = result.replace(f"{{{form}}}", simple) + result = result.replace(f"{{{form}*}}", wildcard) # Expand {?param1,param2,...} query parameter blocks def _expand_query_block(match: re.Match[str]) -> str: diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py index f5f160194..1ff4db22e 100644 --- a/fastmcp_slim/fastmcp/server/providers/proxy.py +++ b/fastmcp_slim/fastmcp/server/providers/proxy.py @@ -12,7 +12,6 @@ import inspect import time from collections.abc import Awaitable, Callable, Sequence from typing import TYPE_CHECKING, Any, cast -from urllib.parse import quote import anyio import httpx @@ -43,7 +42,7 @@ from fastmcp.prompts import Message, Prompt, PromptResult from fastmcp.prompts.base import PromptArgument from fastmcp.resources import Resource, ResourceTemplate from fastmcp.resources.base import ResourceContent, ResourceResult -from fastmcp.resources.template import expand_uri_template, extract_query_params +from fastmcp.resources.template import expand_uri_template from fastmcp.server.context import Context from fastmcp.server.dependencies import get_context from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext @@ -393,18 +392,10 @@ class ProxyTemplate(ResourceTemplate): ) -> ProxyResource: """Create a resource from the template by calling the remote server.""" # don't use the provided uri, because it may not be the same as the - # uri_template on the remote server. - # quote params to ensure they are valid for the uri_template + # uri_template on the remote server. expand_uri_template percent-encodes + # path and query values so the backend URI round-trips correctly. backend_template = self._backend_uri_template or self.uri_template - # Normalize to underscored keys to match how match_uri_template normalizes incoming params - query_param_names = { - p.replace("-", "_") for p in extract_query_params(backend_template) - } - quoted_params = { - k: (v if k in query_param_names else quote(str(v), safe="")) - for k, v in params.items() - } - parameterized_uri = expand_uri_template(backend_template, quoted_params) + parameterized_uri = expand_uri_template(backend_template, params) client = await self._get_client() async with client: result = await client.read_resource(parameterized_uri) diff --git a/loq.toml b/loq.toml index 18f31e50f..654c59997 100644 --- a/loq.toml +++ b/loq.toml @@ -42,6 +42,10 @@ max_lines = 1096 path = "fastmcp_slim/fastmcp/tools/tool_transform.py" max_lines = 1004 +[[rules]] +path = "tests/resources/test_resource_template.py" +max_lines = 1016 + [[rules]] path = "tests/server/providers/openapi/test_openapi_features.py" max_lines = 1029 diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 174b81bea..d4238cd94 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -951,6 +951,30 @@ class TestExpandUriTemplate: result = expand_uri_template("test://{x}", {"x": "foo", "unused": "bar"}) assert result == "test://foo" + @pytest.mark.parametrize( + "template, params, expected", + [ + # Simple {var} matches a single segment, so "/" must be encoded. + ( + "data://{path}/info", + {"path": "hello/world"}, + "data://hello%2Fworld/info", + ), + ("test://{x}", {"x": "a b"}, "test://a%20b"), + ("test://{x}", {"x": "a?b"}, "test://a%3Fb"), + ("test://{x}", {"x": "a#b"}, "test://a%23b"), + # Wildcard {var*} may span segments, so "/" is preserved but other + # reserved characters are still encoded. + ("test://{path*}", {"path": "a/b/c"}, "test://a/b/c"), + ("test://{path*}", {"path": "a b/c"}, "test://a%20b/c"), + ], + ) + def test_expand_encodes_reserved_characters( + self, template: str, params: dict[str, str], expected: str + ): + """Path values are percent-encoded so the result round-trips through match.""" + assert expand_uri_template(template, params) == expected + class TestMatchExpandRoundTrip: """match_uri_template and expand_uri_template must agree on the template grammar.""" @@ -972,3 +996,21 @@ class TestMatchExpandRoundTrip: params = match_uri_template(uri, template) assert params is not None assert expand_uri_template(template, params) == uri + + @pytest.mark.parametrize( + "template, params", + [ + ("data://{path}/info", {"path": "hello/world"}), + ("test://{x}", {"x": "a b c"}), + ("test://{x}", {"x": "100%"}), + ("test://{x}/{y}", {"x": "a/b", "y": "c?d"}), + ("test://{path*}", {"path": "a/b/c"}), + ("test://pre/{rest*}", {"rest": "x y/z"}), + ], + ) + def test_match_then_expand_recovers_params( + self, template: str, params: dict[str, str] + ): + """Expanding params and matching them back reproduces the original values.""" + uri = expand_uri_template(template, params) + assert match_uri_template(uri, template) == params