From 9202b5181acbaf244fac7184e6868a6cb78da2f9 Mon Sep 17 00:00:00 2001 From: Gyanu Mayank Date: Fri, 18 Sep 2026 08:47:47 +0530 Subject: [PATCH] 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. --- src/mcp/shared/uri_template.py | 15 +++++++++++++-- .../resources/test_resource_template.py | 6 ++++++ tests/shared/test_uri_template.py | 19 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/mcp/shared/uri_template.py b/src/mcp/shared/uri_template.py index 20d6fa9c2e..310ddfce56 100644 --- a/src/mcp/shared/uri_template.py +++ b/src/mcp/shared/uri_template.py @@ -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. @@ -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: diff --git a/tests/server/mcpserver/resources/test_resource_template.py b/tests/server/mcpserver/resources/test_resource_template.py index b27c77a585..dc84def78f 100644 --- a/tests/server/mcpserver/resources/test_resource_template.py +++ b/tests/server/mcpserver/resources/test_resource_template.py @@ -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() diff --git a/tests/shared/test_uri_template.py b/tests/shared/test_uri_template.py index cddbb82b0b..670f5bcbf6 100644 --- a/tests/shared/test_uri_template.py +++ b/tests/shared/test_uri_template.py @@ -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"