diff --git a/src/mcp/server/fastmcp/resources/templates.py b/src/mcp/server/fastmcp/resources/templates.py index 89a8ceb36b..14e2ca4bc5 100644 --- a/src/mcp/server/fastmcp/resources/templates.py +++ b/src/mcp/server/fastmcp/resources/templates.py @@ -6,6 +6,7 @@ import re from collections.abc import Callable from typing import TYPE_CHECKING, Any +from urllib.parse import unquote from pydantic import BaseModel, Field, validate_call @@ -83,12 +84,16 @@ def from_function( ) def matches(self, uri: str) -> dict[str, Any] | None: - """Check if URI matches template and extract parameters.""" + """Check if URI matches template and extract parameters. + + Extracted parameters are URL-decoded to handle percent-encoded characters. + """ # Convert template to regex pattern pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)") match = re.match(f"^{pattern}$", uri) if match: - return match.groupdict() + # URL-decode all extracted parameter values + return {key: unquote(value) for key, value in match.groupdict().items()} return None async def create_resource( diff --git a/tests/issues/test_973_url_decoding.py b/tests/issues/test_973_url_decoding.py new file mode 100644 index 0000000000..32d5a16cc5 --- /dev/null +++ b/tests/issues/test_973_url_decoding.py @@ -0,0 +1,78 @@ +"""Test that URL-encoded parameters are decoded in resource templates. + +Regression test for https://github.com/modelcontextprotocol/python-sdk/issues/973 +""" + +from mcp.server.fastmcp.resources import ResourceTemplate + + +def test_template_matches_decodes_space(): + """Test that %20 is decoded to space.""" + + def search(query: str) -> str: # pragma: no cover + return f"Results for: {query}" + + template = ResourceTemplate.from_function( + fn=search, + uri_template="search://{query}", + name="search", + ) + + params = template.matches("search://hello%20world") + assert params is not None + assert params["query"] == "hello world" + + +def test_template_matches_decodes_accented_characters(): + """Test that %C3%A9 is decoded to e with accent.""" + + def search(query: str) -> str: # pragma: no cover + return f"Results for: {query}" + + template = ResourceTemplate.from_function( + fn=search, + uri_template="search://{query}", + name="search", + ) + + params = template.matches("search://caf%C3%A9") + assert params is not None + assert params["query"] == "café" + + +def test_template_matches_decodes_complex_phrase(): + """Test complex French phrase from the original issue.""" + + def search(query: str) -> str: # pragma: no cover + return f"Results for: {query}" + + template = ResourceTemplate.from_function( + fn=search, + uri_template="search://{query}", + name="search", + ) + + params = template.matches("search://stick%20correcteur%20teint%C3%A9%20anti-imperfections") + assert params is not None + assert params["query"] == "stick correcteur teinté anti-imperfections" + + +def test_template_matches_preserves_plus_sign(): + """Test that plus sign remains as plus (not converted to space). + + In URI encoding, %20 is space. Plus-as-space is only for + application/x-www-form-urlencoded (HTML forms). + """ + + def search(query: str) -> str: # pragma: no cover + return f"Results for: {query}" + + template = ResourceTemplate.from_function( + fn=search, + uri_template="search://{query}", + name="search", + ) + + params = template.matches("search://hello+world") + assert params is not None + assert params["query"] == "hello+world"