Repository navigation
UriTemplate.match() replaces non-UTF-8 percent-escapes with U+FFFD, corrupting the value handed to resource handlers #3721
Description
Initial Checks
- I confirm that I'm using the newest release of my line (the latest 2.x, or the latest 1.x if I'm still on v1)
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
Release line
2.x (current stable)
Description
A resource URI whose percent-escapes are not valid UTF-8 is matched successfully, but the
captured value reaches the handler with each undecodable octet replaced by U+FFFD. The
original byte is gone and cannot be recovered by the handler.
RFC 3986 §2.1 percent-encoding encodes octets, not UTF-8 text, so file://caf%E9
(latin-1 café) is a well-formed URI. match() accepts it, but returns 'caf�'.
The cause is at every capture site in uri_template.py: urllib.parse.unquote is called
with its default errors="replace" (lines 607, 625, 657, 1015, 1048, 1114). Decoding
silently substitutes U+FFFD rather than failing or preserving the octet.
This also breaks the round-trip that match()'s own docstring claims. The docstring
discusses the separator limitation for {.ext}, but says nothing about lossy decoding:
match('file://caf%E9') -> {'name': 'caf�'}
expand(that) -> 'file://caf%EF%BF%BD' # a different URI
Every operator form is affected, so this is not specific to reserved expansion:
| template | input | captured |
|---|---|---|
file://{name} |
file://caf%E9 |
'caf�' |
file://{+path} |
file://caf%E9 |
'caf�' |
x{#frag} |
x#caf%E9 |
'caf�' |
x{.ext} |
x.caf%E9 |
'caf�' |
x{/seg} |
x/caf%E9 |
'caf�' |
x{;p} |
x;p=caf%E9 |
'caf�' |
x{?q} |
x?q=caf%E9 |
'caf�' |
x{/list*} |
x/a/caf%E9 |
['a', 'caf�'] |
I expected either the octet to be preserved, or the URI to be rejected — not a silent
substitution that the handler cannot detect or undo.
Example Code
import anyio
from mcp import Client
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("demo")
seen = []
@mcp.resource("file://{name}")
def read(name: str) -> str:
seen.append(name)
return f"got {name!r}"
async def main() -> None:
async with Client(mcp) as client:
await client.read_resource("file://caf%E9")
print("handler received:", repr(seen[-1]))
anyio.run(main)$ uv run --frozen python repro.py
handler received: 'caf�'
Possible approaches
Two that restore the contract, both one-line changes at the six call sites:
errors="surrogateescape"— preserves the octet and round-trips exactly:
unquote('caf%E9', errors='surrogateescape')gives'caf\udce9', and
quote(..., errors='surrogateescape')gives back'caf%E9'. Handlers that only
ever see UTF-8 are unaffected; those that care can recover the bytes.errors="strict"and treat theUnicodeDecodeErroras a non-match — the URI then
gets an honest "Unknown resource" instead of corrupted data.
I'd lean towards (1), since it keeps match() a true inverse of expand() and does not
turn a currently-working (if lossy) read into a failure. Happy to go with (2) if you'd
rather not have surrogates reach handler code. I'd like to fix this if you'll take it.
Python & MCP Python SDK
Python 3.14.3
pydantic 2.12.5
mcp / mcp-types 2.3.1.dev10+9f66f376 (main at 9f66f376)
src/mcp/shared/uri_template.py is byte-identical at v2.3.0 and main
(last changed 2026-07-10, 1216c536), so the released 2.3.0 behaves the same.
Found by widening the value alphabet in the SDK's own property test
(test_match_inverts_expand_for_every_parseable_template); its _VALUE_CHARS is
ascii_lowercase + digits, which cannot emit a percent sign or any reserved character.
Activity
nikolas-sapa commentedon Oct 17, 2026
Confirmed the repro across all template operators:
urllib.parse.unquotedefaults toerrors='replace', causing non-UTF-8 percent-encoded octets (e.g.file://caf%E9) to be replaced with U+FFFD (caf), destroying the raw octet before it reaches resource handlers and breaking bidirectional invertibility withexpand().Fix approach: pass
errors="surrogateescape"tounquote()at all capture sites and in_encode()forquote(). Verified all 9 operator forms round-trip cleanly and all 316 unit tests intests/shared/test_uri_template.pypass.A working patch with full regression test coverage is ready on
nikolas-sapa:fix/uritemplate-surrogateescape-3721(prepared in #3724). Could a maintainer assign this issue to me so the PR reopens? Thanks!Varshith-Kali commentedon Oct 18, 2026
Hi, I would like to pick this up. I reproduced the silent U+FFFD substitution on current main across the operator forms in the table, and traced it to the
unquotecalls with defaulterrors="replace"in the match path. My plan: decode captures witherrors="surrogateescape"and encode with the same setting in_encode, so non-UTF-8 octets survive the round trip thematchdocstring claims (match(expand(v)) == v). Could a maintainer assign this issue to me?AI assistance disclosure: this contribution was drafted with AI coding assistance, and I reviewed and tested every line.
nosmile99 commentedon Oct 20, 2026
Confirmed on
main(2af9e30) — reproduced the substitution across all 8 operator forms:The 6 capture sites are
unquote(...)with the defaulterrors="replace"(uri_template.pylines 607, 625, 657, 1015, 1048, 1114), so every undecodable octet is silently swapped for U+FFFD and the original byte is unrecoverable.For the record: #3724 (the
surrogateescapefix) was closed by the require-linked-issue bot for process reasons, not a technical verdict — the approach itself is untested by review. Two shapes look viable: preserve the octet viasurrogateescape(keeps the match + round-trip working), or treat undecodable captures as a match failure. Changing the observable behavior here is a design call, so I'd rather implement whichever the maintainers prefer.If a maintainer is willing to assign me to this issue, I can put up a PR with regression tests (fail pre-fix / pass post-fix) once the direction is picked.
AI disclosure: this verification and any follow-up code were produced with AI assistance (Muse, agentic coding assistant).
Ben-Home commentedon Oct 27, 2026
Both proposed fixes are correct about where the loss happens: the decode sites call
unquotewith noerrorsargument, so the defaultreplaceturns any byte that is not valid UTF-8 into U+FFFD and the round-trip documented onmatchbreaks.One consequence of
errors="surrogateescape"needs correcting before the patch merges, because the failure path is narrower than it looks.unquote("%FF%FE", errors="surrogateescape")returns astrholding lone surrogates in U+DC80 to U+DCFF, and that value does surviveunquoteandquotewith the same setting. It does not fail atjson.dumps(...).encode("utf-8"): with the default escaper the surrogate is emitted as the ASCII sequence\udcff, and encoding that succeeds. What fails is anything that encodes the string itself as UTF-8, sovalue.encode("utf-8"), a text mode file write withencoding="utf-8", or a body built fromjson.dumps(..., ensure_ascii=False)all raiseUnicodeEncodeError: surrogates not allowed.os.fsencodeis the one path that survives, which is why thesurrogateescapefamily exists.So the invariant is not "use surrogateescape in both directions". It is that every consumer of the matched value has to be known, because the value can no longer be encoded as UTF-8. Two ways to keep the fix honest:
Keep the value as bytes internally.
unquote_to_byteson match,quote_from_byteson expand, neither leaves the byte domain. I checked the round trip on%FF%FEand it returnsb"\xff\xfe"intact. That is what RFC 3986 section 2.1 describes, and the lossy decision happens once, at the boundary where a handler asks for text.If
surrogateescapestays, put the actual serialization path in the test table rather than the defaultdumpscall, since the default call is the one that passes.Either way the test that separates a real fix from a passing one is the same: percent-encode a byte that is illegal in UTF-8, expand, serialize it the way the transport will, and assert you get the original octet back. String equality alone passes for both designs and distinguishes neither.