mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
fix(resources): round-trip path values with reserved characters in URI templates (#4368)
This commit is contained in:
parent
a13e48ea6f
commit
094908042c
4 changed files with 63 additions and 18 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
4
loq.toml
4
loq.toml
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue