Initial Checks
Release line
2.x (current stable)
Description
UriTemplate copies literal runs into the expansion verbatim. RFC 6570 §3.1 requires a literal that the URI grammar does not allow — ucschar (café), a space — to be pct-encoded as UTF-8 when the template is expanded, so expand() currently returns a string that is not a valid RFC 3986 URI:
UriTemplate.parse("file:///docs/café/{name}").expand({"name": "a.txt"})
# 'file:///docs/café/a.txt' (uritemplate-test "Literal Encoding" expects caf%C3%A9)
match() is built from the same verbatim literals, so it accepts only that invalid form and rejects the encoded one:
t = UriTemplate.parse("file:///docs/café/{name}")
t.match("file:///docs/caf%C3%A9/a.txt") # None
t.match("file:///docs/café/a.txt") # {'name': 'a.txt'}
That second line is the part that bites in practice. A resource URI crosses the wire as a pydantic AnyUrl, which pct-encodes non-ASCII and spaces (AnyUrl('file:///docs/café/a.txt') → file:///docs/caf%C3%A9/a.txt). So the encoded form is the only one a server ever sees, and the template can never match it: the resource is listed in resources/templates/list and is permanently unreadable. The same applies to a literal space (file:///my docs/{name} → my%20docs).
I hit this while building resource templates whose paths carry Spanish accents, which is ordinary for non-English servers.
Expected: expand() pct-encodes literals per §3.1, and match() accepts the encoded URI a conforming client sends, so an expanded URI round-trips.
Note the direction of the behavior change this implies: after the fix, the raw (unencoded) form stops matching. I believe that is correct — it is not a valid RFC 3986 URI and it is not what reaches a server over the wire — but it is a visible change for anyone calling UriTemplate.match() directly with a raw IRI, so it seems worth your call rather than mine. Accepting both forms is possible (a fallback scan over the raw atoms) at the cost of a second pass and an ambiguity to document.
I have a patch — the literal encoding applied in both directions, sharing the existing _encode helper — plus unit tests, a routing test, and the official uritemplate-test "Literal Encoding" vector. The full suite passes (5978 passed, 10 skipped, 1 xfailed; baseline 5968), and the new assertions fail on main. Happy to open a PR if you'd like it and can assign this to me.
Example Code
import anyio
from mcp.client.client import Client
from mcp.server.mcpserver import MCPServer
mcp = MCPServer()
@mcp.resource("file:///docs/café/{name}")
def doc(name: str) -> str:
return f"contents of {name}"
async def main() -> None:
async with Client(mcp) as client:
templates = await client.list_resource_templates()
print(templates.resource_templates[0].uri_template)
# file:///docs/café/{name}
# what a conforming client puts on the wire (AnyUrl pct-encodes it)
await client.read_resource("file:///docs/caf%C3%A9/a.txt")
# McpError: Unknown resource: file:///docs/caf%C3%A9/a.txt
anyio.run(main)
Python & MCP Python SDK
Python 3.11.3, mcp-python-sdk main @ 6affe5c0d3588fd1705713b3703dc68015cfe3eb
Disclosure: this was found and written with Claude Code — it ran the official uritemplate-test vectors against mcp.shared.uri_template, reduced the failure to this case, and drafted this report and the patch. I reviewed it before posting and can walk through the change.
Initial Checks
Release line
2.x (current stable)
Description
UriTemplatecopies literal runs into the expansion verbatim. RFC 6570 §3.1 requires a literal that the URI grammar does not allow —ucschar(café), a space — to be pct-encoded as UTF-8 when the template is expanded, soexpand()currently returns a string that is not a valid RFC 3986 URI:match()is built from the same verbatim literals, so it accepts only that invalid form and rejects the encoded one:That second line is the part that bites in practice. A resource URI crosses the wire as a pydantic
AnyUrl, which pct-encodes non-ASCII and spaces (AnyUrl('file:///docs/café/a.txt')→file:///docs/caf%C3%A9/a.txt). So the encoded form is the only one a server ever sees, and the template can never match it: the resource is listed inresources/templates/listand is permanently unreadable. The same applies to a literal space (file:///my docs/{name}→my%20docs).I hit this while building resource templates whose paths carry Spanish accents, which is ordinary for non-English servers.
Expected:
expand()pct-encodes literals per §3.1, andmatch()accepts the encoded URI a conforming client sends, so an expanded URI round-trips.Note the direction of the behavior change this implies: after the fix, the raw (unencoded) form stops matching. I believe that is correct — it is not a valid RFC 3986 URI and it is not what reaches a server over the wire — but it is a visible change for anyone calling
UriTemplate.match()directly with a raw IRI, so it seems worth your call rather than mine. Accepting both forms is possible (a fallback scan over the raw atoms) at the cost of a second pass and an ambiguity to document.I have a patch — the literal encoding applied in both directions, sharing the existing
_encodehelper — plus unit tests, a routing test, and the official uritemplate-test "Literal Encoding" vector. The full suite passes (5978 passed, 10 skipped, 1 xfailed; baseline 5968), and the new assertions fail onmain. Happy to open a PR if you'd like it and can assign this to me.Example Code
Python & MCP Python SDK
Disclosure: this was found and written with Claude Code — it ran the official uritemplate-test vectors against
mcp.shared.uri_template, reduced the failure to this case, and drafted this report and the patch. I reviewed it before posting and can walk through the change.