From 42a73b0c4ca1a85defcb386e33c5f4694fc4410c Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 15 Jan 2026 15:35:40 +0100 Subject: [PATCH 1/3] test: reproduce #973 Github-Issue: #973 --- tests/issues/test_973_url_decoding.py | 80 +++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/issues/test_973_url_decoding.py diff --git a/tests/issues/test_973_url_decoding.py b/tests/issues/test_973_url_decoding.py new file mode 100644 index 0000000000..bc8c894bb4 --- /dev/null +++ b/tests/issues/test_973_url_decoding.py @@ -0,0 +1,80 @@ +"""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 + + +class TestUrlParameterDecoding: + """Test URL parameter decoding in resource templates.""" + + def test_template_matches_decodes_space(self): + """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(self): + """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"] == "cafe" # encoded as UTF-8 + + def test_template_matches_decodes_complex_phrase(self): + """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 teinte anti-imperfections" + + def test_template_matches_preserves_plus_sign(self): + """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" \ No newline at end of file From 8b53094fb6932d7aac6df5c6bd43647fad877187 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 15 Jan 2026 15:37:40 +0100 Subject: [PATCH 2/3] fix: URL-decode parameters extracted from resource templates (#973) Github-Issue: #973 Reported-by: codeonym --- src/mcp/server/fastmcp/resources/templates.py | 9 +++++++-- tests/issues/test_973_url_decoding.py | 10 ++++------ 2 files changed, 11 insertions(+), 8 deletions(-) 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 index bc8c894bb4..506efe30c2 100644 --- a/tests/issues/test_973_url_decoding.py +++ b/tests/issues/test_973_url_decoding.py @@ -39,7 +39,7 @@ def search(query: str) -> str: # pragma: no cover params = template.matches("search://caf%C3%A9") assert params is not None - assert params["query"] == "cafe" # encoded as UTF-8 + assert params["query"] == "café" def test_template_matches_decodes_complex_phrase(self): """Test complex French phrase from the original issue.""" @@ -53,11 +53,9 @@ def search(query: str) -> str: # pragma: no cover name="search", ) - params = template.matches( - "search://stick%20correcteur%20teint%C3%A9%20anti-imperfections" - ) + params = template.matches("search://stick%20correcteur%20teint%C3%A9%20anti-imperfections") assert params is not None - assert params["query"] == "stick correcteur teinte anti-imperfections" + assert params["query"] == "stick correcteur teinté anti-imperfections" def test_template_matches_preserves_plus_sign(self): """Test that plus sign remains as plus (not converted to space). @@ -77,4 +75,4 @@ def search(query: str) -> str: # pragma: no cover params = template.matches("search://hello+world") assert params is not None - assert params["query"] == "hello+world" \ No newline at end of file + assert params["query"] == "hello+world" From 6dd588a87e95a44d0709b1a8669fba7f7bd6a0b4 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 16 Jan 2026 09:40:39 +0100 Subject: [PATCH 3/3] refactor: convert test class to functions --- tests/issues/test_973_url_decoding.py | 106 +++++++++++++------------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/tests/issues/test_973_url_decoding.py b/tests/issues/test_973_url_decoding.py index 506efe30c2..32d5a16cc5 100644 --- a/tests/issues/test_973_url_decoding.py +++ b/tests/issues/test_973_url_decoding.py @@ -6,73 +6,73 @@ from mcp.server.fastmcp.resources import ResourceTemplate -class TestUrlParameterDecoding: - """Test URL parameter decoding in resource templates.""" +def test_template_matches_decodes_space(): + """Test that %20 is decoded to space.""" - def test_template_matches_decodes_space(self): - """Test that %20 is decoded to space.""" + def search(query: str) -> str: # pragma: no cover + return f"Results for: {query}" - 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", + ) - 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" - params = template.matches("search://hello%20world") - assert params is not None - assert params["query"] == "hello world" - def test_template_matches_decodes_accented_characters(self): - """Test that %C3%A9 is decoded to e with accent.""" +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}" + 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", - ) + 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é" + params = template.matches("search://caf%C3%A9") + assert params is not None + assert params["query"] == "café" - def test_template_matches_decodes_complex_phrase(self): - """Test complex French phrase from the original issue.""" - def search(query: str) -> str: # pragma: no cover - return f"Results for: {query}" +def test_template_matches_decodes_complex_phrase(): + """Test complex French phrase from the original issue.""" - template = ResourceTemplate.from_function( - fn=search, - uri_template="search://{query}", - name="search", - ) + def search(query: str) -> str: # pragma: no cover + return f"Results for: {query}" - params = template.matches("search://stick%20correcteur%20teint%C3%A9%20anti-imperfections") - assert params is not None - assert params["query"] == "stick correcteur teinté anti-imperfections" + template = ResourceTemplate.from_function( + fn=search, + uri_template="search://{query}", + name="search", + ) - def test_template_matches_preserves_plus_sign(self): - """Test that plus sign remains as plus (not converted to space). + params = template.matches("search://stick%20correcteur%20teint%C3%A9%20anti-imperfections") + assert params is not None + assert params["query"] == "stick correcteur teinté anti-imperfections" - 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}" +def test_template_matches_preserves_plus_sign(): + """Test that plus sign remains as plus (not converted to space). - template = ResourceTemplate.from_function( - fn=search, - uri_template="search://{query}", - name="search", - ) + In URI encoding, %20 is space. Plus-as-space is only for + application/x-www-form-urlencoded (HTML forms). + """ - params = template.matches("search://hello+world") - assert params is not None - assert params["query"] == "hello+world" + 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"