Skip to content

Commit 93e742b

Browse files
committed
feat: lenient query param matching for {?var} and {&var}
UriTemplate.match() now handles trailing {?...}/{&...} expressions via urllib.parse.parse_qs instead of positional regex. Query parameters are matched order-agnostic, partial params are accepted, and unrecognized params are ignored. Parameters absent from the URI stay absent from the result so downstream function defaults apply. This restores the round-trip invariant for query expansion: RFC 6570 skips undefined vars during expand(), so {?q,lang} with only q set produces ?q=foo. Previously match() rejected that output; now it returns {'q': 'foo'}. Templates with a literal ? in the path portion (?fixed=1{&page}) fall back to strict regex matching since the URI split won't align with the template's expression boundary. The docs example at docs/server/resources.md (logs://{service}{?since,level} with Python defaults) now works as documented.
1 parent c1a1787 commit 93e742b

File tree

3 files changed

+184
-39
lines changed

3 files changed

+184
-39
lines changed

src/mcp/shared/uri_template.py

Lines changed: 136 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from collections.abc import Mapping, Sequence
1717
from dataclasses import dataclass, field
1818
from typing import Literal, cast
19-
from urllib.parse import quote, unquote
19+
from urllib.parse import parse_qs, quote, unquote
2020

2121
__all__ = ["InvalidUriTemplate", "Operator", "UriTemplate", "Variable"]
2222

@@ -201,6 +201,8 @@ class UriTemplate:
201201
_parts: tuple[_Part, ...] = field(repr=False, compare=False)
202202
_variables: tuple[Variable, ...] = field(repr=False, compare=False)
203203
_pattern: re.Pattern[str] = field(repr=False, compare=False)
204+
_path_variables: tuple[Variable, ...] = field(repr=False, compare=False)
205+
_query_variables: tuple[Variable, ...] = field(repr=False, compare=False)
204206

205207
@staticmethod
206208
def is_template(value: str) -> bool:
@@ -253,8 +255,22 @@ def parse(
253255
)
254256

255257
parts, variables = _parse(template, max_expressions=max_expressions)
256-
pattern = _build_pattern(parts)
257-
return cls(template=template, _parts=parts, _variables=variables, _pattern=pattern)
258+
259+
# Trailing {?...}/{&...} expressions are matched leniently via
260+
# parse_qs instead of regex: order-agnostic, partial, ignores
261+
# extras. The path portion keeps regex matching.
262+
path_parts, query_vars = _split_query_tail(parts)
263+
path_vars = variables[: len(variables) - len(query_vars)]
264+
pattern = _build_pattern(path_parts)
265+
266+
return cls(
267+
template=template,
268+
_parts=parts,
269+
_variables=variables,
270+
_pattern=pattern,
271+
_path_variables=path_vars,
272+
_query_variables=query_vars,
273+
)
258274

259275
@property
260276
def variables(self) -> tuple[Variable, ...]:
@@ -355,6 +371,19 @@ def match(self, uri: str, *, max_uri_length: int = DEFAULT_MAX_URI_LENGTH) -> di
355371
>>> t.match("/files/a/b/c")
356372
{'path': ['a', 'b', 'c']}
357373
374+
**Query parameters** (``{?q,lang}`` at the end of a template)
375+
are matched leniently: order-agnostic, partial, and unrecognized
376+
params are ignored. Absent params are omitted from the result so
377+
downstream function defaults can apply::
378+
379+
>>> t = UriTemplate.parse("logs://{service}{?since,level}")
380+
>>> t.match("logs://api")
381+
{'service': 'api'}
382+
>>> t.match("logs://api?level=error")
383+
{'service': 'api', 'level': 'error'}
384+
>>> t.match("logs://api?level=error&since=5m&utm=x")
385+
{'service': 'api', 'since': '5m', 'level': 'error'}
386+
358387
Args:
359388
uri: A concrete URI string.
360389
max_uri_length: Maximum permitted length of the input URI.
@@ -369,54 +398,125 @@ def match(self, uri: str, *, max_uri_length: int = DEFAULT_MAX_URI_LENGTH) -> di
369398
"""
370399
if len(uri) > max_uri_length:
371400
return None
401+
402+
if self._query_variables:
403+
# Two-phase: regex matches the path, parse_qs handles the
404+
# query. Query params may be partial, reordered, or include
405+
# extras; absent params stay absent so downstream defaults
406+
# can apply.
407+
path, _, query = uri.partition("?")
408+
m = self._pattern.fullmatch(path)
409+
if m is None:
410+
return None
411+
result = _extract_path(m, self._path_variables)
412+
if result is None:
413+
return None
414+
if query:
415+
parsed = parse_qs(query, keep_blank_values=True)
416+
for var in self._query_variables:
417+
if var.name in parsed:
418+
result[var.name] = parsed[var.name][0]
419+
return result
420+
372421
m = self._pattern.fullmatch(uri)
373422
if m is None:
374423
return None
424+
return _extract_path(m, self._variables)
375425

376-
result: dict[str, str | list[str]] = {}
377-
# One capture group per variable, emitted in template order.
378-
for var, raw in zip(self._variables, m.groups()):
379-
spec = _OPERATOR_SPECS[var.operator]
426+
def __str__(self) -> str:
427+
return self.template
380428

381-
if var.explode:
382-
# Explode capture holds the whole run including separators,
383-
# e.g. "/a/b/c" or ";keys=a;keys=b". Split and decode each.
384-
if not raw:
385-
result[var.name] = []
429+
430+
def _extract_path(m: re.Match[str], variables: tuple[Variable, ...]) -> dict[str, str | list[str]] | None:
431+
"""Decode regex capture groups into a variable-name mapping.
432+
433+
Handles scalar and explode variables. Named explode (``;``) strips
434+
and validates the ``name=`` prefix per item, returning ``None`` on
435+
mismatch.
436+
"""
437+
result: dict[str, str | list[str]] = {}
438+
# One capture group per variable, emitted in template order.
439+
for var, raw in zip(variables, m.groups()):
440+
spec = _OPERATOR_SPECS[var.operator]
441+
442+
if var.explode:
443+
# Explode capture holds the whole run including separators,
444+
# e.g. "/a/b/c" or ";keys=a;keys=b". Split and decode each.
445+
if not raw:
446+
result[var.name] = []
447+
continue
448+
segments: list[str] = []
449+
prefix = f"{var.name}="
450+
for seg in raw.split(spec.separator):
451+
if not seg: # leading separator produces an empty first item
386452
continue
387-
segments: list[str] = []
388-
prefix = f"{var.name}="
389-
for seg in raw.split(spec.separator):
390-
if not seg: # leading separator produces an empty first item
391-
continue
392-
if spec.named:
393-
# Named explode emits name=value per item (or bare
394-
# name for ; with empty value). Validate the name
395-
# and strip the prefix before decoding.
396-
if seg.startswith(prefix):
397-
seg = seg[len(prefix) :]
398-
elif seg == var.name:
399-
seg = ""
400-
else:
401-
return None
402-
segments.append(unquote(seg))
403-
result[var.name] = segments
404-
else:
405-
result[var.name] = unquote(raw)
453+
if spec.named:
454+
# Named explode emits name=value per item (or bare
455+
# name for ; with empty value). Validate the name
456+
# and strip the prefix before decoding.
457+
if seg.startswith(prefix):
458+
seg = seg[len(prefix) :]
459+
elif seg == var.name:
460+
seg = ""
461+
else:
462+
return None
463+
segments.append(unquote(seg))
464+
result[var.name] = segments
465+
else:
466+
result[var.name] = unquote(raw)
406467

407-
return result
468+
return result
408469

409-
def __str__(self) -> str:
410-
return self.template
470+
471+
def _split_query_tail(
472+
parts: tuple[_Part, ...],
473+
) -> tuple[tuple[_Part, ...], tuple[Variable, ...]]:
474+
"""Separate trailing ``?``/``&`` expressions from the path portion.
475+
476+
Lenient query matching (order-agnostic, partial, ignores extras)
477+
applies when a template ends with one or more consecutive ``?``/``&``
478+
expressions and the preceding path portion contains no literal
479+
``?``. If the path has a literal ``?`` (e.g., ``?fixed=1{&page}``),
480+
the URI's ``?`` split won't align with the template's expression
481+
boundary, so strict regex matching is used instead.
482+
483+
Returns:
484+
A pair ``(path_parts, query_vars)``. If lenient matching does
485+
not apply, ``query_vars`` is empty and ``path_parts`` is the
486+
full input.
487+
"""
488+
split = len(parts)
489+
for i in range(len(parts) - 1, -1, -1):
490+
part = parts[i]
491+
if isinstance(part, _Expression) and part.operator in ("?", "&"):
492+
split = i
493+
else:
494+
break
495+
496+
if split == len(parts):
497+
return parts, ()
498+
499+
# If the path portion contains a literal ?, the URI's ? won't align
500+
# with our template split. Fall back to strict regex.
501+
for part in parts[:split]:
502+
if isinstance(part, str) and "?" in part:
503+
return parts, ()
504+
505+
query_vars: list[Variable] = []
506+
for part in parts[split:]:
507+
assert isinstance(part, _Expression)
508+
query_vars.extend(part.variables)
509+
510+
return parts[:split], tuple(query_vars)
411511

412512

413513
def _build_pattern(parts: tuple[_Part, ...]) -> re.Pattern[str]:
414514
"""Compile a regex that matches URIs produced by this template.
415515
416516
Walks parts in order: literals are ``re.escape``'d, expressions
417517
become capture groups. One group is emitted per variable, in the
418-
same order as ``UriTemplate._variables``, so ``match.groups()`` can
419-
be zipped directly.
518+
same order as the variables appearing in ``parts``, so
519+
``match.groups()`` can be zipped directly.
420520
421521
Raises:
422522
re.error: Only if pattern assembly is buggy — should not happen

tests/server/mcpserver/test_server.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,36 @@ async def test_resource_decorator_rejects_malformed_template(self):
159159
with pytest.raises(InvalidUriTemplate, match="Unclosed expression"):
160160
mcp.resource("file://{name")
161161

162+
async def test_resource_optional_query_params_use_function_defaults(self):
163+
"""Omitted {?...} query params should fall through to the
164+
handler's Python defaults. Partial and reordered params work."""
165+
mcp = MCPServer()
166+
167+
@mcp.resource("logs://{service}{?since,level}")
168+
def tail_logs(service: str, since: str = "1h", level: str = "info") -> str:
169+
return f"{service}|{since}|{level}"
170+
171+
async with Client(mcp) as client:
172+
# No query → all defaults
173+
r = await client.read_resource("logs://api")
174+
assert isinstance(r.contents[0], TextResourceContents)
175+
assert r.contents[0].text == "api|1h|info"
176+
177+
# Partial query → one default
178+
r = await client.read_resource("logs://api?since=15m")
179+
assert isinstance(r.contents[0], TextResourceContents)
180+
assert r.contents[0].text == "api|15m|info"
181+
182+
# Reordered, both present
183+
r = await client.read_resource("logs://api?level=error&since=5m")
184+
assert isinstance(r.contents[0], TextResourceContents)
185+
assert r.contents[0].text == "api|5m|error"
186+
187+
# Extra param ignored
188+
r = await client.read_resource("logs://api?since=2h&utm=x")
189+
assert isinstance(r.contents[0], TextResourceContents)
190+
assert r.contents[0].text == "api|2h|info"
191+
162192
async def test_resource_security_default_rejects_traversal(self):
163193
mcp = MCPServer()
164194

tests/shared/test_uri_template.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -339,11 +339,22 @@ def test_expand_rejects_invalid_value_types(value: object):
339339
("item{;keys*}", "item;keys=a;keys=b", {"keys": ["a", "b"]}),
340340
("item{;keys*}", "item;keys=a;keys;keys=b", {"keys": ["a", "", "b"]}),
341341
("item{;keys*}", "item", {"keys": []}),
342-
# Level 3: query
342+
# Level 3: query. Lenient matching: partial, reordered, and
343+
# extra params are all accepted. Absent params stay absent.
343344
("search{?q}", "search?q=hello", {"q": "hello"}),
344345
("search{?q}", "search?q=", {"q": ""}),
346+
("search{?q}", "search", {}),
345347
("search{?q,lang}", "search?q=mcp&lang=en", {"q": "mcp", "lang": "en"}),
346-
# Level 3: query continuation
348+
("search{?q,lang}", "search?lang=en&q=mcp", {"q": "mcp", "lang": "en"}),
349+
("search{?q,lang}", "search?q=mcp", {"q": "mcp"}),
350+
("search{?q,lang}", "search", {}),
351+
("search{?q}", "search?q=mcp&utm=x&ref=y", {"q": "mcp"}),
352+
# URL-encoded query values are decoded
353+
("search{?q}", "search?q=hello%20world", {"q": "hello world"}),
354+
# Multiple ?/& expressions collected together
355+
("api{?v}{&page,limit}", "api?limit=10&v=2", {"v": "2", "limit": "10"}),
356+
# Level 3: query continuation with literal ? falls back to
357+
# strict regex (template-order, all-present required)
347358
("?a=1{&b}", "?a=1&b=2", {"b": "2"}),
348359
# Explode: path segments as list
349360
("/files{/path*}", "/files/a/b/c", {"path": ["a", "b", "c"]}),
@@ -365,7 +376,6 @@ def test_match(template: str, uri: str, expected: dict[str, str | list[str]]):
365376
("file://docs/{name}", "file://other/readme.txt"),
366377
("{a}/{b}", "foo"),
367378
("file{.ext}", "file"),
368-
("search{?q}", "search"),
369379
("static", "different"),
370380
# Anchoring: trailing extra component must not match. Guards
371381
# against a refactor from fullmatch() to match() or search().
@@ -483,6 +493,11 @@ def test_match_explode_encoded_separator_in_segment():
483493
("item{;id}", {"id": ""}),
484494
("item{;keys*}", {"keys": ["a", "b", "c"]}),
485495
("item{;keys*}", {"keys": ["a", "", "b"]}),
496+
# Partial query expansion round-trips: expand omits undefined
497+
# vars, match leaves them absent from the result.
498+
("logs://{service}{?since,level}", {"service": "api"}),
499+
("logs://{service}{?since,level}", {"service": "api", "since": "1h"}),
500+
("logs://{service}{?since,level}", {"service": "api", "since": "1h", "level": "error"}),
486501
],
487502
)
488503
def test_roundtrip_expand_then_match(template: str, variables: dict[str, str | list[str]]):

0 commit comments

Comments
 (0)