Skip to content

Commit 9202b51

Browse files
committed
fix(uri-template): percent-encode non-ASCII and space literals
RFC 6570 section 3.1 requires literals that are not valid in a URI to be UTF-8 percent-encoded on expand. match() used the same raw literals, so a resource template with cafe or a space in the path was listed but could never be read once AnyUrl encoded the URI on the wire.
1 parent 6affe5c commit 9202b51

3 files changed

Lines changed: 38 additions & 2 deletions

File tree

src/mcp/shared/uri_template.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,17 @@ def _encode(value: str, *, allow_reserved: bool) -> str:
225225
return "".join(out)
226226

227227

228+
def _encode_literal(text: str) -> str:
229+
"""Percent-encode a template literal per RFC 6570 §3.1.
230+
231+
Characters the URI grammar does not allow (non-ASCII, space, and other
232+
non-unreserved/non-reserved octets) are UTF-8 percent-encoded. Reserved
233+
and unreserved characters, and existing ``%XX`` triplets, are left as-is
234+
so ``file://`` paths stay intact.
235+
"""
236+
return _encode(text, allow_reserved=True)
237+
238+
228239
def _expand_expression(expr: _Expression, variables: Mapping[str, str | Sequence[str]]) -> str:
229240
"""Expand a single ``{...}`` expression into its URI fragment.
230241
@@ -734,12 +745,12 @@ def _parse(template: str, *, max_variables: int) -> tuple[list[_Part], list[Vari
734745

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

740751
if brace > i:
741752
# Literal text between cursor and the brace.
742-
parts.append(template[i:brace])
753+
parts.append(_encode_literal(template[i:brace]))
743754

744755
end = template.find("}", brace)
745756
if end == -1:

tests/server/mcpserver/resources/test_resource_template.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ def test_matches_null_byte_check_can_be_disabled():
9191
assert t.matches("file://docs/key%00.txt") == {"name": "key\x00.txt"}
9292

9393

94+
def test_matches_pct_encoded_non_ascii_literal():
95+
t = _make("file:///docs/café/{name}")
96+
assert t.matches("file:///docs/caf%C3%A9/a.txt") == {"name": "a.txt"}
97+
assert t.matches("file:///docs/café/a.txt") is None
98+
99+
94100
def test_security_rejection_does_not_fall_through_to_next_template():
95101
# A strict template's security rejection must halt iteration, not
96102
# fall through to a later permissive template. Previously matches()

tests/shared/test_uri_template.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,25 @@ def test_expand_encodes_special_chars_in_simple():
479479
assert t.expand({"v": "a&b=c"}) == "a%26b%3Dc"
480480

481481

482+
def test_expand_encodes_non_ascii_and_space_literals():
483+
# RFC 6570 §3.1 / uritemplate-test "Literal Encoding"
484+
t = UriTemplate.parse("file:///docs/café/{name}")
485+
assert t.expand({"name": "a.txt"}) == "file:///docs/caf%C3%A9/a.txt"
486+
487+
spaced = UriTemplate.parse("file:///my docs/{name}")
488+
assert spaced.expand({"name": "a.txt"}) == "file:///my%20docs/a.txt"
489+
490+
491+
def test_match_accepts_pct_encoded_literals_not_raw_iri():
492+
t = UriTemplate.parse("file:///docs/café/{name}")
493+
assert t.match("file:///docs/caf%C3%A9/a.txt") == {"name": "a.txt"}
494+
assert t.match("file:///docs/café/a.txt") is None
495+
496+
spaced = UriTemplate.parse("file:///my docs/{name}")
497+
assert spaced.match("file:///my%20docs/a.txt") == {"name": "a.txt"}
498+
assert spaced.match("file:///my docs/a.txt") is None
499+
500+
482501
def test_expand_preserves_special_chars_in_reserved():
483502
t = UriTemplate.parse("{+v}")
484503
assert t.expand({"v": "a&b=c"}) == "a&b=c"

0 commit comments

Comments
 (0)