diff --git a/src/mcp/shared/uri_template.py b/src/mcp/shared/uri_template.py index 20d6fa9c2e..a9ad4dcd20 100644 --- a/src/mcp/shared/uri_template.py +++ b/src/mcp/shared/uri_template.py @@ -1080,21 +1080,35 @@ def _scan_prefix( if atom.ifemp: # RFC §3.2.7 ifemp: ;name=val for non-empty, bare ;name for - # empty. Decide which form is present without falling through - # to the stop-char scan when the value is empty. + # empty. A '=' in the URI means both readings are plausible, + # so the value reading is tried first, bounded to the next + # literal, and the empty reading is the fallback. The bounded + # span matters: the stop-char span would take 'end=end' from + # ';a=end=end/x' (the ';' operator's stop set is ';/?#', so + # '=' does not end the value) and then fail the literal. + if pos < limit and uri[pos] == "=": + value_start = pos + 1 + latest = value_start + while latest < limit and uri[latest] not in stops: + latest += 1 + end = uri.find(nxt.text, value_start, latest + len(nxt.text)) + if end != -1: + result[var.name] = unquote(uri[value_start:end]) + pos = end + continue + if uri.startswith(nxt.text, pos): + # Empty value: the following literal begins at '='. + result[var.name] = "" + continue + return None if uri.startswith(nxt.text, pos): # Following literal begins immediately: value is empty. - # Checked before '=' so a literal that itself starts - # with '=' is not mistaken for the ifemp separator. result[var.name] = "" continue - if pos < limit and uri[pos] == "=": - pos += 1 # value follows; fall through to the scan - else: - # The following literal does not start here and there is - # no '=': the URI's name continued past the template's - # (e.g. ;keys vs ;key) — no parse. - return None + # No '=' and the following literal does not start here: the + # URI's name continued past the template's (e.g. ;keys vs + # ;key), so there is no parse. + return None # Latest valid end: the var stops at the first stop-char or # the scan limit, whichever comes first. diff --git a/tests/shared/test_uri_template.py b/tests/shared/test_uri_template.py index cddbb82b0b..57774f841d 100644 --- a/tests/shared/test_uri_template.py +++ b/tests/shared/test_uri_template.py @@ -712,12 +712,34 @@ def test_match_greedy_rejection_paths(template: str, uri: str): # check runs first so '=' is not mistaken for the ifemp separator. ("api{;key}=base/{+path}", "api;key=base/a/b", {"key": "", "path": "a/b"}), ("api{;key}=base/{+path}", "api;key=v=base/x", {"key": "v", "path": "x"}), + # The value reading is tried before the empty one, bounded to the + # next literal, so a value equal to the literal's stem still parses. + ("{;a}=end{+rest}", ";a=end=end/x", {"a": "end", "rest": "/x"}), + ("{;a}=end{+rest}", ";a=x=end/x", {"a": "x", "rest": "/x"}), + # Empty value still detected when no value reading is possible. + ("{;a}=end{+rest}", ";a=end/x", {"a": "", "rest": "/x"}), + ("{;a}=end", ";a=end", {"a": ""}), ], ) def test_match_prefix_scan_edge_cases(template: str, uri: str, expected: dict[str, str]): assert UriTemplate.parse(template).match(uri) == expected +def test_ifemp_value_literal_prefix_is_ambiguous_by_construction(): + """Documented ambiguity, not a bug. + + For ``{;a}=end{+rest}`` on ``;a=end=end/x`` both ``{a: "end", rest: + "/x"}`` and ``{a: "", rest: "=end/x"}`` expand to the same URI. Match + returns the ``;name=value`` reading for consistency with the suffix + scan, which already prefers it for ``{;a}=end{/rest}``. + """ + t = UriTemplate.parse("{;a}=end{+rest}") + value_reading = {"a": "end", "rest": "/x"} + empty_reading = {"a": "", "rest": "=end/x"} + assert t.expand(value_reading) == t.expand(empty_reading) == ";a=end=end/x" + assert t.match(";a=end=end/x") == value_reading + + @pytest.mark.parametrize( ("template", "uri", "expected"), [ @@ -860,6 +882,13 @@ def test_match_explode_encoded_separator_in_segment(): ("logs://{service}{?since,level}", {"service": "api", "since": "1h"}), ("logs://{service}{?since,level}", {"service": "api", "since": "1h", "level": "error"}), ("api{;key}=base/{+path}", {"key": "", "path": "a/b"}), + # ifemp followed by a literal beginning with '=': a value equal to + # the literal's stem must round-trip (issue #3852). + ("{;a}=end{+rest}", {"a": "end", "rest": "/x"}), + ("{;a}=end{+rest}", {"a": "", "rest": "/x"}), + ("{;a}=end{+rest}", {"a": "x", "rest": "/x"}), + ("/api{;mode}=fast{/id}", {"mode": "fast", "id": "7"}), + ("/api{;mode}=fast{/id}", {"mode": "slow", "id": "7"}), ], ) def test_roundtrip_expand_then_match(template: str, variables: dict[str, str | list[str]]):