/
/
/
1"""URI parsing for MCP resources."""
2
3from __future__ import annotations
4
5import re
6from dataclasses import dataclass
7
8ALLOWED_SCHEMES: frozenset[str] = frozenset({"library", "player", "queue"})
9ALLOWED_TYPES: frozenset[str] = frozenset(
10 {"artist", "album", "track", "playlist", "radio", "podcast", "audiobook"}
11)
12_ID_RE = re.compile(r"^[A-Za-z0-9._:%@\-]+$")
13
14
15@dataclass(frozen=True)
16class ResourceURI:
17 """A parsed MCP resource URI: ``<scheme>://[<type>/]<id>``."""
18
19 scheme: str
20 type: str | None
21 id: str
22
23
24def parse_resource_uri(uri: str) -> ResourceURI:
25 """
26 Parse and validate a resource URI.
27
28 :param uri: input URI (``library://artist/123``, ``player://kitchen``).
29 :raises ValueError: if scheme/type/id are missing, unknown, or contain
30 characters that could enable path traversal.
31 """
32 if "://" not in uri:
33 msg = f"Invalid URI (missing scheme): {uri!r}"
34 raise ValueError(msg)
35 scheme, _, rest = uri.partition("://")
36 if scheme not in ALLOWED_SCHEMES:
37 msg = f"Unsupported scheme: {scheme!r}"
38 raise ValueError(msg)
39 if not rest or ".." in rest:
40 msg = f"Invalid URI body: {rest!r}"
41 raise ValueError(msg)
42
43 if "/" in rest:
44 if scheme != "library":
45 msg = f"{scheme}:// URIs must not contain a path separator, got {uri!r}"
46 raise ValueError(msg)
47 type_, _, identifier = rest.partition("/")
48 if type_ not in ALLOWED_TYPES:
49 msg = f"Unknown library type: {type_!r}"
50 raise ValueError(msg)
51 if not identifier or not _ID_RE.match(identifier):
52 msg = f"Invalid id: {identifier!r}"
53 raise ValueError(msg)
54 return ResourceURI(scheme=scheme, type=type_, id=identifier)
55
56 if scheme == "library":
57 msg = f"library:// URIs require a type segment, got {uri!r}"
58 raise ValueError(msg)
59 if not _ID_RE.match(rest):
60 msg = f"Invalid id: {rest!r}"
61 raise ValueError(msg)
62 return ResourceURI(scheme=scheme, type=None, id=rest)
63