fix(resources): round-trip path values with reserved characters in URI templates (#4368)

This commit is contained in:
Jeremiah Lowin 2026-06-24 13:56:04 -04:00 committed by GitHub
commit 094908042c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 63 additions and 18 deletions

View file

@ -125,17 +125,25 @@ def expand_uri_template(uri_template: str, params: dict[str, Any]) -> str:
""" """
result = uri_template 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 # Params use underscored keys (e.g. user_id) but templates may use
# hyphens (e.g. {user-id}), so try both forms. # hyphens (e.g. {user-id}), so try both forms.
for key, value in params.items(): for key, value in params.items():
value_str = str(value) value_str = str(value)
result = result.replace(f"{{{key}}}", value_str) simple = quote(value_str, safe="")
result = result.replace(f"{{{key}*}}", value_str) wildcard = quote(value_str, safe="/")
forms = [key]
hyphenated = key.replace("_", "-") hyphenated = key.replace("_", "-")
if hyphenated != key: if hyphenated != key:
result = result.replace(f"{{{hyphenated}}}", value_str) forms.append(hyphenated)
result = result.replace(f"{{{hyphenated}*}}", value_str) for form in forms:
result = result.replace(f"{{{form}}}", simple)
result = result.replace(f"{{{form}*}}", wildcard)
# Expand {?param1,param2,...} query parameter blocks # Expand {?param1,param2,...} query parameter blocks
def _expand_query_block(match: re.Match[str]) -> str: def _expand_query_block(match: re.Match[str]) -> str:

View file

@ -12,7 +12,6 @@ import inspect
import time import time
from collections.abc import Awaitable, Callable, Sequence from collections.abc import Awaitable, Callable, Sequence
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from urllib.parse import quote
import anyio import anyio
import httpx import httpx
@ -43,7 +42,7 @@ from fastmcp.prompts import Message, Prompt, PromptResult
from fastmcp.prompts.base import PromptArgument from fastmcp.prompts.base import PromptArgument
from fastmcp.resources import Resource, ResourceTemplate from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.resources.base import ResourceContent, ResourceResult 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.context import Context
from fastmcp.server.dependencies import get_context from fastmcp.server.dependencies import get_context
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
@ -393,18 +392,10 @@ class ProxyTemplate(ResourceTemplate):
) -> ProxyResource: ) -> ProxyResource:
"""Create a resource from the template by calling the remote server.""" """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 # don't use the provided uri, because it may not be the same as the
# uri_template on the remote server. # uri_template on the remote server. expand_uri_template percent-encodes
# quote params to ensure they are valid for the uri_template # path and query values so the backend URI round-trips correctly.
backend_template = self._backend_uri_template or self.uri_template backend_template = self._backend_uri_template or self.uri_template
# Normalize to underscored keys to match how match_uri_template normalizes incoming params parameterized_uri = expand_uri_template(backend_template, 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)
client = await self._get_client() client = await self._get_client()
async with client: async with client:
result = await client.read_resource(parameterized_uri) result = await client.read_resource(parameterized_uri)

View file

@ -42,6 +42,10 @@ max_lines = 1096
path = "fastmcp_slim/fastmcp/tools/tool_transform.py" path = "fastmcp_slim/fastmcp/tools/tool_transform.py"
max_lines = 1004 max_lines = 1004
[[rules]]
path = "tests/resources/test_resource_template.py"
max_lines = 1016
[[rules]] [[rules]]
path = "tests/server/providers/openapi/test_openapi_features.py" path = "tests/server/providers/openapi/test_openapi_features.py"
max_lines = 1029 max_lines = 1029

View file

@ -951,6 +951,30 @@ class TestExpandUriTemplate:
result = expand_uri_template("test://{x}", {"x": "foo", "unused": "bar"}) result = expand_uri_template("test://{x}", {"x": "foo", "unused": "bar"})
assert result == "test://foo" 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: class TestMatchExpandRoundTrip:
"""match_uri_template and expand_uri_template must agree on the template grammar.""" """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) params = match_uri_template(uri, template)
assert params is not None assert params is not None
assert expand_uri_template(template, params) == uri 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