Repository navigation
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 registered as backup://nightly{.ext} cannot be read when the extension holds a dot. UriTemplate.expand({"ext": "tar.gz"}) gives backup://nightly.tar.gz, because . is unreserved. match() on that same string returns None, and resources/read answers "Unknown resource" for a URI the template itself produced.
The match() docstring lists this exact case as an inherent RFC 6570 §1.4 limitation. I think that overstates it. The ambiguity needs a second capture that could claim the dot, as in {name}{.ext}. When {.ext} is the first capture after a literal, the anchored branch of _scan_suffix knows where the value starts. Nothing else can take that dot. A client that reads the template from resources/templates/list also cannot know it must send %2E.
The fix bounds a non-explode {.var} in that branch by /, ? and # alone. The docstring sentence would then cover only the case with a capture on the left, which stays unmatched. With the change, tests/shared/test_uri_template.py plus tests/server/mcpserver go from 949 passed on c9356a2 to 950. The new case is ("file{.ext}", {"ext": "tar.gz"}) in test_roundtrip_expand_then_match, and it fails without the change.
Example Code
import anyio
from mcp import Client
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("demo")
@mcp.resource("backup://nightly{.ext}")
def nightly(ext: str) -> str:
return f"archive in {ext}"
async def main() -> None:
template = mcp._resource_manager.list_templates()[0].parsed_template
uri = template.expand({"ext": "tar.gz"})
print("expand:", uri)
async with Client(mcp) as client:
for u in ("backup://nightly.zip", uri):
try:
r = await client.read_resource(u)
print(u, "->", r.contents[0].text)
except Exception as e:
print(u, "->", type(e).__name__, e)
anyio.run(main)$ .venv/bin/python -I repro_dot.py
expand: backup://nightly.tar.gz
backup://nightly.zip -> archive in zip
backup://nightly.tar.gz -> MCPError Unknown resource: backup://nightly.tar.gz
# not run: the full test suite, only the two test paths named above
Python & MCP Python SDK
Python 3.14.7
mcp at main c9356a2; uri_template.py is unchanged since v2.3.0
@maxisbey, this is the template module from #2356. As the reporter I would like to fix it, and the change is ready. Would you narrow the limitation to the anchored case?
Activity
Ben-Home commentedon Oct 13, 2026
chrikrah commentedon Oct 20, 2026
One cost I left out of the report. With the anchored branch bounded by
/?#,file{.ext}matchesfile.tar.gz, whichfile{+path}already matches, so a server registering both resolves by registration order atresource_manager.py:119. That overlap exists on2af9e30today for a single dot:@maxisbey, you wrote that limitation in #2356. I'd narrow the docstring limitation to the unanchored case, because this widens an overlap that already exists and
{name}{.ext}stays unmatched. Would you take that, or keep the docstring as written? @Kludex, copying you since you're merging this week.sunhouy commentedon Nov 5, 2026
I hit this while looking for something to work on and it reproduces exactly as described, so I traced it and have a patch that passes locally. Dropping the findings here first since
require-linked-issue.ymlwants the issue assigned before a PR can stay open.Root cause.
_STOP_CHARS[Operator.label]is"./?#"— the label operator lists its own separator as a stop char. In_scan_suffix,earliestwalks left until it lands on a stop char, so forfile{.ext}it parks just after the last.; the anchored branch then doesstart < earliest -> return None. But{.var}means "emit a dot, then the value", soext="tar.gz"expands to.tar.gz. When the expression follows a literal, that literal already pins where the value begins and there is no capture to its left that could claim the dot, so the dots inside the value can only belong to the value.Shape of the fix, in
_scan_suffix, tested beforeearliestis computed:Then the existing scan uses
scan_stopsinstead ofstops. The other three branches are untouched.On @Ben-Home's first point: the condition keys on
var.operator == "."per expression, not on the explode flag, so{+var}cannot pick up the restriction through a shared branch. I checked that explicitly —{+path}againsta.b,a/banda/b?c#dbehaves identically before and after (a/b?c#dreturnsNonein both, that's the query/fragment split upstream, not this scan).On @chrikrah's docstring question: I read that as a yes, so the
match()docstring no longer states the limitation flatly. It now says the round-trip holds for{.var}right after a literal, and that the dot is ambiguous again once another capture sits to its left:file{.ext}file.tar.gzNone{'ext': 'tar.gz'}backup://nightly{.ext}backup://nightly.tar.gzNone{'ext': 'tar.gz'}{name}{.ext}f.tar.gz{'name': 'f.tar', 'ext': 'gz'}file{.a}{;b}file.tar.gz;b=1None{'a': 'tar.gz', 'b': '1'}Tests added: three cases to the
test_roundtrip_expand_then_matchparametrization, plus one test pinning that{name}{.ext}stays ambiguous and one pinning that structural chars like;still terminate the value. Running the file: 4 failed / 308 passed against the original source, 312 passed with the patch.Two things I did not do, in fairness to what was asked:
resource_manager.py:119territory, so I left it alone. Worth noting the direction though: makingmatch()accept more URIs does widen what a server will route tofile{.ext}, and if someone gates access on the captured value the more permissive match is the one that answers.Happy to send the PR here if it gets assigned, or to open it right away if a maintainer prefers to add
help wantedinstead.