Skip to content

Commit fc25b34

Browse files
committed
fix(uri_template): use surrogateescape for unquote and quote to preserve non-UTF-8 octets (#3721)
1 parent 9f66f37 commit fc25b34

2 files changed

Lines changed: 31 additions & 9 deletions

File tree

‎src/mcp/shared/uri_template.py‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -210,18 +210,18 @@ def _encode(value: str, *, allow_reserved: bool) -> str:
210210
followed by two hex digits is still encoded to ``%25``.
211211
"""
212212
if not allow_reserved:
213-
return quote(value, safe="")
213+
return quote(value, safe="", errors="surrogateescape")
214214

215215
# Reserved expansion: walk the string, pass through triplets as-is,
216216
# quote the gaps between them. A bare % with no triplet lands in a
217217
# gap and gets encoded normally.
218218
out: list[str] = []
219219
last = 0
220220
for m in _PCT_TRIPLET_RE.finditer(value):
221-
out.append(quote(value[last : m.start()], safe=_RESERVED))
221+
out.append(quote(value[last : m.start()], safe=_RESERVED, errors="surrogateescape"))
222222
out.append(m.group())
223223
last = m.end()
224-
out.append(quote(value[last:], safe=_RESERVED))
224+
out.append(quote(value[last:], safe=_RESERVED, errors="surrogateescape"))
225225
return "".join(out)
226226

227227

@@ -604,7 +604,7 @@ def _parse_query(query: str) -> dict[str, str]:
604604
for pair in query.split("&"):
605605
name, _, value = pair.partition("=")
606606
if name and name not in result:
607-
result[name] = unquote(value)
607+
result[name] = unquote(value, errors="surrogateescape")
608608
return result
609609

610610

@@ -622,7 +622,7 @@ def _extract_greedy(var: Variable, raw: str) -> str | list[str] | None:
622622
if not var.explode:
623623
if any(c in stops for c in raw):
624624
return None
625-
return unquote(raw)
625+
return unquote(raw, errors="surrogateescape")
626626

627627
sep = spec.separator
628628
if not raw:
@@ -654,7 +654,7 @@ def _extract_greedy(var: Variable, raw: str) -> str | list[str] | None:
654654
seg = ""
655655
else:
656656
return None
657-
segments.append(unquote(seg))
657+
segments.append(unquote(seg, errors="surrogateescape"))
658658
return segments
659659

660660

@@ -1012,7 +1012,7 @@ def _scan_suffix(
10121012
eq = uri.find("=", earliest, pos)
10131013
if eq == -1:
10141014
return None
1015-
result[var.name] = unquote(uri[eq + 1 : pos])
1015+
result[var.name] = unquote(uri[eq + 1 : pos], errors="surrogateescape")
10161016
pos = eq
10171017
i -= 1
10181018
continue
@@ -1045,7 +1045,7 @@ def _scan_suffix(
10451045
return None
10461046
start = idx + len(prev.text)
10471047

1048-
result[var.name] = unquote(uri[start:pos])
1048+
result[var.name] = unquote(uri[start:pos], errors="surrogateescape")
10491049
pos = start
10501050
i -= 1
10511051
return result, pos
@@ -1111,6 +1111,6 @@ def _scan_prefix(
11111111
if end == -1:
11121112
return None
11131113

1114-
result[var.name] = unquote(uri[pos:end])
1114+
result[var.name] = unquote(uri[pos:end], errors="surrogateescape")
11151115
pos = end
11161116
return result, pos

‎tests/shared/test_uri_template.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,28 @@ def test_match_decodes_percent_encoding():
764764
assert t.match("file://docs/hello%20world.txt") == {"name": "hello world.txt"}
765765

766766

767+
@pytest.mark.parametrize(
768+
("template", "uri", "expected"),
769+
[
770+
("file://{name}", "file://caf%E9", {"name": "caf\udce9"}),
771+
("file://{+path}", "file://caf%E9", {"path": "caf\udce9"}),
772+
("x{#frag}", "x#caf%E9", {"frag": "caf\udce9"}),
773+
("x{.ext}", "x.caf%E9", {"ext": "caf\udce9"}),
774+
("x{/seg}", "x/caf%E9", {"seg": "caf\udce9"}),
775+
("x{;p}", "x;p=caf%E9", {"p": "caf\udce9"}),
776+
("x{?q}", "x?q=caf%E9", {"q": "caf\udce9"}),
777+
("x{/list*}", "x/a/caf%E9", {"list": ["a", "caf\udce9"]}),
778+
("prefix-{id}-greedy/{+path}", "prefix-caf%E9-greedy/rest", {"id": "caf\udce9", "path": "rest"}),
779+
],
780+
)
781+
def test_match_non_utf8_percent_encoding_surrogateescape(template: str, uri: str, expected: dict[str, str | list[str]]):
782+
t = UriTemplate.parse(template)
783+
matched = t.match(uri)
784+
assert matched == expected
785+
assert t.expand(matched) == uri
786+
assert t.match(t.expand(matched)) == matched
787+
788+
767789
def test_match_escapes_template_literals():
768790
# Regression: previous impl didn't escape . in literals, making it
769791
# a regex wildcard. "fileXtxt" should NOT match "file.txt/{id}".

0 commit comments

Comments
 (0)