Skip to content
Closed
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
15 changes: 13 additions & 2 deletions src/mcp/shared/uri_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,17 @@ def _encode(value: str, *, allow_reserved: bool) -> str:
return "".join(out)


def _encode_literal(text: str) -> str:
"""Percent-encode a template literal per RFC 6570 §3.1.

Characters the URI grammar does not allow (non-ASCII, space, and other
non-unreserved/non-reserved octets) are UTF-8 percent-encoded. Reserved
and unreserved characters, and existing ``%XX`` triplets, are left as-is
so ``file://`` paths stay intact.
"""
return _encode(text, allow_reserved=True)


def _expand_expression(expr: _Expression, variables: Mapping[str, str | Sequence[str]]) -> str:
"""Expand a single ``{...}`` expression into its URI fragment.

Expand Down Expand Up @@ -734,12 +745,12 @@ def _parse(template: str, *, max_variables: int) -> tuple[list[_Part], list[Vari

if brace == -1:
# No more expressions; everything left is a trailing literal.
parts.append(template[i:])
parts.append(_encode_literal(template[i:]))
break

if brace > i:
# Literal text between cursor and the brace.
parts.append(template[i:brace])
parts.append(_encode_literal(template[i:brace]))

end = template.find("}", brace)
if end == -1:
Expand Down
6 changes: 6 additions & 0 deletions tests/server/mcpserver/resources/test_resource_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ def test_matches_null_byte_check_can_be_disabled():
assert t.matches("file://docs/key%00.txt") == {"name": "key\x00.txt"}


def test_matches_pct_encoded_non_ascii_literal():
t = _make("file:///docs/café/{name}")
assert t.matches("file:///docs/caf%C3%A9/a.txt") == {"name": "a.txt"}
assert t.matches("file:///docs/café/a.txt") is None


def test_security_rejection_does_not_fall_through_to_next_template():
# A strict template's security rejection must halt iteration, not
# fall through to a later permissive template. Previously matches()
Expand Down
19 changes: 19 additions & 0 deletions tests/shared/test_uri_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,25 @@ def test_expand_encodes_special_chars_in_simple():
assert t.expand({"v": "a&b=c"}) == "a%26b%3Dc"


def test_expand_encodes_non_ascii_and_space_literals():
# RFC 6570 §3.1 / uritemplate-test "Literal Encoding"
t = UriTemplate.parse("file:///docs/café/{name}")
assert t.expand({"name": "a.txt"}) == "file:///docs/caf%C3%A9/a.txt"

spaced = UriTemplate.parse("file:///my docs/{name}")
assert spaced.expand({"name": "a.txt"}) == "file:///my%20docs/a.txt"


def test_match_accepts_pct_encoded_literals_not_raw_iri():
t = UriTemplate.parse("file:///docs/café/{name}")
assert t.match("file:///docs/caf%C3%A9/a.txt") == {"name": "a.txt"}
assert t.match("file:///docs/café/a.txt") is None

spaced = UriTemplate.parse("file:///my docs/{name}")
assert spaced.match("file:///my%20docs/a.txt") == {"name": "a.txt"}
assert spaced.match("file:///my docs/a.txt") is None


def test_expand_preserves_special_chars_in_reserved():
t = UriTemplate.parse("{+v}")
assert t.expand({"v": "a&b=c"}) == "a&b=c"
Expand Down
Loading