@@ -472,7 +472,9 @@ def match(self, uri: str, *, max_uri_length: int = DEFAULT_MAX_URI_LENGTH) -> di
472472 will not match across a literal ``/`` in the URI (the scan stops
473473 there), but a percent-encoded ``%2F`` that decodes to ``/`` is
474474 accepted as part of the value. Path-safety validation belongs at
475- a higher layer; see :mod:`mcp.shared.path_security`.
475+ a higher layer; see :mod:`mcp.shared.path_security`. A URI whose
476+ percent-escapes don't decode as UTF-8 (e.g. ``%FF``) does not
477+ match.
476478
477479 Example::
478480
@@ -518,6 +520,14 @@ def match(self, uri: str, *, max_uri_length: int = DEFAULT_MAX_URI_LENGTH) -> di
518520 if len (uri ) > max_uri_length :
519521 return None
520522
523+ try :
524+ return self ._match (uri )
525+ except UnicodeDecodeError :
526+ # A %XX escape that isn't valid UTF-8 would otherwise decode
527+ # to U+FFFD and hand the handler a different value.
528+ return None
529+
530+ def _match (self , uri : str ) -> dict [str , str | list [str ]] | None :
521531 if self ._query_variables :
522532 # Two-phase: scan matches the path, the query is split and
523533 # decoded manually. Query params may be partial, reordered,
@@ -583,6 +593,10 @@ def __str__(self) -> str:
583593 return self .template
584594
585595
596+ def _decode (value : str ) -> str :
597+ return unquote (value , errors = "strict" )
598+
599+
586600def _parse_query (query : str ) -> dict [str , str ]:
587601 """Parse a query string into a name→value mapping.
588602
@@ -604,7 +618,7 @@ def _parse_query(query: str) -> dict[str, str]:
604618 for pair in query .split ("&" ):
605619 name , _ , value = pair .partition ("=" )
606620 if name and name not in result :
607- result [name ] = unquote (value )
621+ result [name ] = _decode (value )
608622 return result
609623
610624
@@ -622,7 +636,7 @@ def _extract_greedy(var: Variable, raw: str) -> str | list[str] | None:
622636 if not var .explode :
623637 if any (c in stops for c in raw ):
624638 return None
625- return unquote (raw )
639+ return _decode (raw )
626640
627641 sep = spec .separator
628642 if not raw :
@@ -654,7 +668,7 @@ def _extract_greedy(var: Variable, raw: str) -> str | list[str] | None:
654668 seg = ""
655669 else :
656670 return None
657- segments .append (unquote (seg ))
671+ segments .append (_decode (seg ))
658672 return segments
659673
660674
@@ -1012,7 +1026,7 @@ def _scan_suffix(
10121026 eq = uri .find ("=" , earliest , pos )
10131027 if eq == - 1 :
10141028 return None
1015- result [var .name ] = unquote (uri [eq + 1 : pos ])
1029+ result [var .name ] = _decode (uri [eq + 1 : pos ])
10161030 pos = eq
10171031 i -= 1
10181032 continue
@@ -1045,7 +1059,7 @@ def _scan_suffix(
10451059 return None
10461060 start = idx + len (prev .text )
10471061
1048- result [var .name ] = unquote (uri [start :pos ])
1062+ result [var .name ] = _decode (uri [start :pos ])
10491063 pos = start
10501064 i -= 1
10511065 return result , pos
@@ -1111,6 +1125,6 @@ def _scan_prefix(
11111125 if end == - 1 :
11121126 return None
11131127
1114- result [var .name ] = unquote (uri [pos :end ])
1128+ result [var .name ] = _decode (uri [pos :end ])
11151129 pos = end
11161130 return result , pos
0 commit comments