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
# 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:

View file

@ -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)

View file

@ -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

View file

@ -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