Repository: modelcontextprotocol/python-sdk

Issue:
URL Parameters Not Decoded in Dynamic Resources
### Initial Checks

- [ ] I confirm that I'm using the latest version of MCP Python SDK
- [x] I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue

### Description

Dynamic URL resource parameters in MCP protocol are not automatically URL-decoded, causing encoded characters (like %20 for spaces and %C3%A9 for accented characters) to be passed as-is to resource handlers instead of their decoded equivalents.


Here is a simple log for the bug: 
- **before**
```
2025-06-17 14:43:44,385 [INFO] kn-graph: Found 0 products matching 'stick%20correcteur%20teint%C3%A9%20anti-imperfections%20rechargeable%20au%20zinc' - (logger.py:226)
```

- **after explicit decode**
```
2025-06-17 14:55:10,173 [INFO] kn-graph: Found 10 products matching 'stick correcteur teinté anti-imperfections rechargeable au zinc' - (logger.py:226)
```

### Example Code

```Python
@mcp.resource("products://search/{product_identifier}")
    def find_similar_products(product_identifier: str):
        """
        Find similar products based on a given product identifier.
        :param product_identifier: The identifier of the product (product name or code).
        :return: Similar products or an error message if none are found.
        """
        print(product_identifier)
        """
         -- walkaround: explicit decoding
        from urllib.parse import unquote
        print(unquote(product_identifier))
        """
```

### Python & MCP Python SDK

```Text
# uv pip list | grep "mcp"
mcp                          1.9.2
# python --version
Python 3.13.4
```

Discussion hints:

Solution patch:
diff --git a/src/mcp/server/fastmcp/resources/templates.py b/src/mcp/server/fastmcp/resources/templates.py
index 89a8ceb36..14e2ca4bc 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(


Test patch:
diff --git a/tests/issues/test_973_url_decoding.py b/tests/issues/test_973_url_decoding.py
new file mode 100644
index 000000000..32d5a16cc
--- /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"