Skip to content

UriTemplate.match() cannot read back prefix{.ext} when ext holds a dot, though nothing else can claim it #3693

Open

Description

@chrikrah

Initial Checks

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

  1. added
    bugSomething isn't working
    v2Affects the v2 line (2.x on main)
    on Oct 12, 2026
  2. Ben-Home commented on Oct 13, 2026

    @Ben-Home
  3. chrikrah commented on Oct 20, 2026

    @chrikrah
    Author

    One cost I left out of the report. With the anchored branch bounded by /?#, file{.ext} matches file.tar.gz, which file{+path} already matches, so a server registering both resolves by registration order at resource_manager.py:119. That overlap exists on 2af9e30 today for a single dot:

    $ python3 -I probe.py src/mcp/shared/uri_template.py    # exported from 2af9e30, unchanged
    file{.ext} file.zip {'ext': 'zip'}
    file{+path} file.zip {'path': '.zip'}
    file{.ext} file.tar.gz None
    file{+path} file.tar.gz {'path': '.tar.gz'}
    

    @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.

  4. sunhouy commented on Nov 5, 2026

    @sunhouy

    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.yml wants 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, earliest walks left until it lands on a stop char, so for file{.ext} it parks just after the last .; the anchored branch then does start < earliest -> return None. But {.var} means "emit a dot, then the value", so ext="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 before earliest is computed:

    own_separator_stops = not var.explode and var.operator == "." and prev is not None
    scan_stops = stops
    if own_separator_stops:
        scan_stops = "".join(c for c in stops if c != _OPERATOR_SPECS["."].separator)

    Then the existing scan uses scan_stops instead of stops. 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} against a.b, a/b and a/b?c#d behaves identically before and after (a/b?c#d returns None in 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:

    template URI before after
    file{.ext} file.tar.gz None {'ext': 'tar.gz'}
    backup://nightly{.ext} backup://nightly.tar.gz None {'ext': 'tar.gz'}
    {name}{.ext} f.tar.gz {'name': 'f.tar', 'ext': 'gz'} unchanged
    file{.a}{;b} file.tar.gz;b=1 None {'a': 'tar.gz', 'b': '1'}

    Tests added: three cases to the test_roundtrip_expand_then_match parametrization, 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:

    • The parametrized cartesian from point 2 is still just a handful of hand-picked pairs, not (expression form × value set). If that's a requirement for landing this I'll build it out.
    • The overlap precedence from point 3 is resource_manager.py:119 territory, so I left it alone. Worth noting the direction though: making match() accept more URIs does widen what a server will route to file{.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 wanted instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingv2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions