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

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