Skip to content

Commit 3b8aadd

Browse files
committed
feat: add URI length guard to UriTemplate.match()
Adds a max_uri_length keyword argument (default 64 KiB) that returns None for oversized inputs before regex evaluation. Guards against resource exhaustion from pathologically long URIs, particularly on stdio transport where there is no inherent message size limit. Consistent with the existing max_length/max_expressions limits on parse(); the default is exported as DEFAULT_MAX_URI_LENGTH.
1 parent b278925 commit 3b8aadd

File tree

2 files changed

+30
-3
lines changed

2 files changed

+30
-3
lines changed

src/mcp/shared/uri_template.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
DEFAULT_MAX_TEMPLATE_LENGTH = 1_000_000
3232
DEFAULT_MAX_EXPRESSIONS = 10_000
33+
DEFAULT_MAX_URI_LENGTH = 65_536
3334

3435
# RFC 3986 reserved characters, kept unencoded by {+var} and {#var}.
3536
_RESERVED = ":/?#[]@!$&'()*+,;="
@@ -333,7 +334,7 @@ def expand(self, variables: Mapping[str, str | Sequence[str]]) -> str:
333334
out.append(_expand_expression(part, variables))
334335
return "".join(out)
335336

336-
def match(self, uri: str) -> dict[str, str | list[str]] | None:
337+
def match(self, uri: str, *, max_uri_length: int = DEFAULT_MAX_URI_LENGTH) -> dict[str, str | list[str]] | None:
337338
"""Match a concrete URI against this template and extract variables.
338339
339340
This is the inverse of :meth:`expand`. The URI is matched against
@@ -368,13 +369,19 @@ def match(self, uri: str) -> dict[str, str | list[str]] | None:
368369
369370
Args:
370371
uri: A concrete URI string.
372+
max_uri_length: Maximum permitted length of the input URI.
373+
Oversized inputs return ``None`` without regex evaluation,
374+
guarding against resource exhaustion.
371375
372376
Returns:
373377
A mapping from variable names to decoded values (``str`` for
374378
scalar variables, ``list[str]`` for explode variables), or
375-
``None`` if the URI does not match the template or a decoded
376-
value violates structural integrity.
379+
``None`` if the URI does not match the template, a decoded
380+
value violates structural integrity, or the URI exceeds
381+
``max_uri_length``.
377382
"""
383+
if len(uri) > max_uri_length:
384+
return None
378385
m = self._pattern.fullmatch(uri)
379386
if m is None:
380387
return None

tests/shared/test_uri_template.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,26 @@ def test_match_bare_encoded_delimiter_rejected():
427427
assert t.match("file://docs/%2F") is None
428428

429429

430+
def test_match_rejects_oversized_uri():
431+
t = UriTemplate.parse("{var}")
432+
assert t.match("x" * 100, max_uri_length=50) is None
433+
434+
435+
def test_match_accepts_uri_within_custom_limit():
436+
t = UriTemplate.parse("{var}")
437+
assert t.match("x" * 100, max_uri_length=200) == {"var": "x" * 100}
438+
439+
440+
def test_match_default_uri_length_limit():
441+
from mcp.shared.uri_template import DEFAULT_MAX_URI_LENGTH
442+
443+
t = UriTemplate.parse("{+var}")
444+
# Just at the limit: should match
445+
assert t.match("x" * DEFAULT_MAX_URI_LENGTH) is not None
446+
# One over: should reject
447+
assert t.match("x" * (DEFAULT_MAX_URI_LENGTH + 1)) is None
448+
449+
430450
def test_match_structural_integrity_per_explode_segment():
431451
t = UriTemplate.parse("/files{/path*}")
432452
# Each segment checked independently

0 commit comments

Comments
 (0)