LeoWalker's picture
Implement nine-tool Spotify MCP v2 suite (#3)
93a887a unverified
Raw
History Blame Contribute Delete
3.75 kB
"""Shared strict request and response contracts for Spotify MCP tools."""
from __future__ import annotations
import re
from enum import StrEnum
from typing import Any
from urllib.parse import urlparse
from pydantic import BaseModel, ConfigDict, Field
class StrictModel(BaseModel):
"""Base for MCP-owned structures: reject misspelled and unknown fields."""
model_config = ConfigDict(extra="forbid", use_enum_values=True)
class SpotifyType(StrEnum):
ALBUM = "album"
ARTIST = "artist"
AUDIOBOOK = "audiobook"
CHAPTER = "chapter"
EPISODE = "episode"
PLAYLIST = "playlist"
SHOW = "show"
TRACK = "track"
class SpotifyReferenceInput(StrictModel):
"""A Spotify ID, URI, or open.spotify.com URL plus type when it cannot be inferred."""
value: str = Field(min_length=1, description="Spotify ID, URI, or open.spotify.com URL.")
type: SpotifyType | None = Field(
default=None, description="Required only when value is a bare Spotify ID."
)
def normalized(self, *, allowed: set[SpotifyType] | None = None) -> SpotifyReference:
value = self.value.strip()
item_type = self.type
item_id = value
uri_match = re.fullmatch(r"spotify:([a-z]+):([^:?/]+)", value)
if uri_match:
item_type = SpotifyType(uri_match.group(1))
item_id = uri_match.group(2)
elif value.startswith("http://") or value.startswith("https://"):
parsed = urlparse(value)
if parsed.hostname not in {"open.spotify.com", "www.open.spotify.com"}:
raise ValueError("Spotify URLs must use open.spotify.com")
parts = [part for part in parsed.path.split("/") if part]
if parts and parts[0].startswith("intl-"):
parts = parts[1:]
if len(parts) < 2:
raise ValueError("Spotify URL must contain an item type and ID")
item_type = SpotifyType(parts[0])
item_id = parts[1]
elif item_type is None:
raise ValueError("type is required when value is a bare Spotify ID")
if not re.fullmatch(r"[A-Za-z0-9]+", item_id):
raise ValueError("Spotify ID contains invalid characters")
if allowed is not None and item_type not in allowed:
values = ", ".join(sorted(allowed_type.value for allowed_type in allowed))
raise ValueError(f"Spotify item type must be one of: {values}")
assert item_type is not None
return SpotifyReference(id=item_id, type=item_type, uri=f"spotify:{item_type}:{item_id}")
class SpotifyReference(StrictModel):
id: str
type: SpotifyType
uri: str
class OffsetPaging(StrictModel):
limit: int = Field(default=20, ge=1, le=50)
offset: int = Field(default=0, ge=0)
max_pages: int = Field(default=1, ge=1, le=10)
class ContractWarning(StrictModel):
code: str
message: str
request_index: int | None = None
action_index: int | None = None
retry_after_seconds: float | None = None
class ToolResponse(StrictModel):
"""Common structured result. Partial successes always retain usable Spotify data."""
status: str = Field(pattern="^(ok|partial|error)$")
data: dict[str, Any] = Field(default_factory=dict)
warnings: list[ContractWarning] = Field(default_factory=list)
class FailureMode(StrEnum):
STOP = "stop"
CONTINUE = "continue"
class PriorActionReference(StrictModel):
"""Reference an object produced by an earlier action in the same ordered request."""
action_index: int = Field(ge=0)
def one_of(*values: Any) -> int:
"""Return the number of non-None alternatives for model validators."""
return sum(value is not None for value in values)