Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/mcp/server/fastmcp/resources/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down
78 changes: 78 additions & 0 deletions tests/issues/test_973_url_decoding.py
Original file line number Diff line number Diff line change
@@ -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"