Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -13,11 +13,20 @@ from fastapi import FastAPI, HTTPException, Query
|
|
| 13 |
from fastapi.responses import HTMLResponse, RedirectResponse
|
| 14 |
from mcp.server.fastmcp import FastMCP
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
load_dotenv()
|
| 17 |
|
|
|
|
| 18 |
SPOTIFY_ACCOUNTS_BASE = "https://accounts.spotify.com"
|
| 19 |
SPOTIFY_API_BASE = "https://api.spotify.com/v1"
|
| 20 |
|
|
|
|
| 21 |
SPOTIFY_CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID", "").strip()
|
| 22 |
SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET", "").strip()
|
| 23 |
SPOTIFY_REDIRECT_URI = os.getenv("SPOTIFY_REDIRECT_URI", "").strip()
|
|
@@ -50,16 +59,19 @@ MCP_SSE_URL = os.getenv(
|
|
| 50 |
"https://pharmaia-demo-mcp-server-spotify.hf.space/gradio_api/mcp/sse",
|
| 51 |
).strip()
|
| 52 |
|
| 53 |
-
#
|
| 54 |
OAUTH_STATE_TTL_SECONDS = 600
|
| 55 |
_oauth_states: dict[str, int] = {}
|
| 56 |
|
| 57 |
|
| 58 |
class SpotifyAuthError(RuntimeError):
|
|
|
|
|
|
|
| 59 |
pass
|
| 60 |
|
| 61 |
|
| 62 |
def _require_spotify_config() -> None:
|
|
|
|
| 63 |
missing = []
|
| 64 |
if not SPOTIFY_CLIENT_ID:
|
| 65 |
missing.append("SPOTIFY_CLIENT_ID")
|
|
@@ -75,6 +87,7 @@ def _require_spotify_config() -> None:
|
|
| 75 |
|
| 76 |
|
| 77 |
def _cleanup_expired_states() -> None:
|
|
|
|
| 78 |
now = int(time.time())
|
| 79 |
expired = [k for k, ts in _oauth_states.items() if (now - ts) > OAUTH_STATE_TTL_SECONDS]
|
| 80 |
for key in expired:
|
|
@@ -82,6 +95,7 @@ def _cleanup_expired_states() -> None:
|
|
| 82 |
|
| 83 |
|
| 84 |
def _new_oauth_state() -> str:
|
|
|
|
| 85 |
_cleanup_expired_states()
|
| 86 |
state = secrets.token_urlsafe(24)
|
| 87 |
_oauth_states[state] = int(time.time())
|
|
@@ -89,6 +103,7 @@ def _new_oauth_state() -> str:
|
|
| 89 |
|
| 90 |
|
| 91 |
def _consume_oauth_state(state: str) -> bool:
|
|
|
|
| 92 |
_cleanup_expired_states()
|
| 93 |
ts = _oauth_states.pop(state, None)
|
| 94 |
if ts is None:
|
|
@@ -97,11 +112,13 @@ def _consume_oauth_state(state: str) -> bool:
|
|
| 97 |
|
| 98 |
|
| 99 |
def _spotify_basic_auth_header() -> str:
|
|
|
|
| 100 |
token = f"{SPOTIFY_CLIENT_ID}:{SPOTIFY_CLIENT_SECRET}".encode("utf-8")
|
| 101 |
return base64.b64encode(token).decode("utf-8")
|
| 102 |
|
| 103 |
|
| 104 |
def _load_tokens() -> dict[str, Any] | None:
|
|
|
|
| 105 |
if not SPOTIFY_TOKEN_FILE.exists():
|
| 106 |
return None
|
| 107 |
try:
|
|
@@ -111,6 +128,7 @@ def _load_tokens() -> dict[str, Any] | None:
|
|
| 111 |
|
| 112 |
|
| 113 |
def _save_tokens(token_payload: dict[str, Any], previous: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
|
|
| 114 |
now = int(time.time())
|
| 115 |
expires_in = int(token_payload.get("expires_in", 3600))
|
| 116 |
|
|
@@ -133,11 +151,13 @@ def _save_tokens(token_payload: dict[str, Any], previous: dict[str, Any] | None
|
|
| 133 |
|
| 134 |
|
| 135 |
def _is_expired(tokens: dict[str, Any]) -> bool:
|
|
|
|
| 136 |
expires_at = int(tokens.get("expires_at", 0))
|
| 137 |
return int(time.time()) >= (expires_at - 30)
|
| 138 |
|
| 139 |
|
| 140 |
def _exchange_code_for_tokens(code: str) -> dict[str, Any]:
|
|
|
|
| 141 |
_require_spotify_config()
|
| 142 |
|
| 143 |
data = {
|
|
@@ -168,6 +188,7 @@ def _exchange_code_for_tokens(code: str) -> dict[str, Any]:
|
|
| 168 |
|
| 169 |
|
| 170 |
def _refresh_access_token(tokens: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
| 171 |
refresh_token = tokens.get("refresh_token")
|
| 172 |
if not refresh_token:
|
| 173 |
raise SpotifyAuthError(
|
|
@@ -201,6 +222,7 @@ def _refresh_access_token(tokens: dict[str, Any]) -> dict[str, Any]:
|
|
| 201 |
|
| 202 |
|
| 203 |
def _get_valid_access_token() -> str:
|
|
|
|
| 204 |
_require_spotify_config()
|
| 205 |
|
| 206 |
tokens = _load_tokens()
|
|
@@ -226,6 +248,10 @@ def _spotify_request(
|
|
| 226 |
params: dict[str, Any] | None = None,
|
| 227 |
json_body: dict[str, Any] | None = None,
|
| 228 |
) -> dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
token = _get_valid_access_token()
|
| 230 |
headers = {"Authorization": f"Bearer {token}"}
|
| 231 |
|
|
@@ -281,6 +307,7 @@ def _spotify_request(
|
|
| 281 |
|
| 282 |
|
| 283 |
def _build_auth_url(state: str, show_dialog: bool = True) -> str:
|
|
|
|
| 284 |
_require_spotify_config()
|
| 285 |
params = {
|
| 286 |
"response_type": "code",
|
|
@@ -294,6 +321,7 @@ def _build_auth_url(state: str, show_dialog: bool = True) -> str:
|
|
| 294 |
|
| 295 |
|
| 296 |
def _token_status() -> dict[str, Any]:
|
|
|
|
| 297 |
tokens = _load_tokens() or {}
|
| 298 |
authenticated = bool(tokens.get("access_token"))
|
| 299 |
return {
|
|
@@ -305,12 +333,14 @@ def _token_status() -> dict[str, Any]:
|
|
| 305 |
|
| 306 |
|
| 307 |
def _token_scope_set() -> set[str]:
|
|
|
|
| 308 |
tokens = _load_tokens() or {}
|
| 309 |
scope_str = str(tokens.get("scope", "")).strip()
|
| 310 |
return {scope for scope in scope_str.split() if scope}
|
| 311 |
|
| 312 |
|
| 313 |
def _require_any_scope(required_scopes: list[str], action: str) -> None:
|
|
|
|
| 314 |
token_scopes = _token_scope_set()
|
| 315 |
if not token_scopes:
|
| 316 |
return
|
|
@@ -323,6 +353,7 @@ def _require_any_scope(required_scopes: list[str], action: str) -> None:
|
|
| 323 |
|
| 324 |
|
| 325 |
def _short_playlist(playlist: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
| 326 |
return {
|
| 327 |
"id": playlist.get("id"),
|
| 328 |
"name": playlist.get("name"),
|
|
@@ -335,6 +366,7 @@ def _short_playlist(playlist: dict[str, Any]) -> dict[str, Any]:
|
|
| 335 |
|
| 336 |
|
| 337 |
def _short_track(track: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
| 338 |
return {
|
| 339 |
"id": track.get("id"),
|
| 340 |
"name": track.get("name"),
|
|
@@ -346,6 +378,7 @@ def _short_track(track: dict[str, Any]) -> dict[str, Any]:
|
|
| 346 |
|
| 347 |
|
| 348 |
def _normalize_track_id(track_ref: str) -> str:
|
|
|
|
| 349 |
value = track_ref.strip()
|
| 350 |
if not value:
|
| 351 |
return ""
|
|
@@ -360,6 +393,7 @@ def _normalize_track_id(track_ref: str) -> str:
|
|
| 360 |
|
| 361 |
|
| 362 |
def _normalize_track_ids(track_ids: list[str], max_items: int = 500) -> list[str]:
|
|
|
|
| 363 |
normalized: list[str] = []
|
| 364 |
seen: set[str] = set()
|
| 365 |
|
|
@@ -378,6 +412,7 @@ def _normalize_track_ids(track_ids: list[str], max_items: int = 500) -> list[str
|
|
| 378 |
|
| 379 |
|
| 380 |
def _normalize_playlist_id(playlist_ref: str) -> str:
|
|
|
|
| 381 |
value = playlist_ref.strip()
|
| 382 |
if not value:
|
| 383 |
return ""
|
|
@@ -392,9 +427,11 @@ def _normalize_playlist_id(playlist_ref: str) -> str:
|
|
| 392 |
|
| 393 |
|
| 394 |
def _chunked(items: list[str], size: int) -> list[list[str]]:
|
|
|
|
| 395 |
return [items[i : i + size] for i in range(0, len(items), size)]
|
| 396 |
|
| 397 |
|
|
|
|
| 398 |
mcp = FastMCP(
|
| 399 |
name="Spotify MCP Server",
|
| 400 |
instructions=(
|
|
@@ -547,6 +584,7 @@ def spotify_get_top_tracks(
|
|
| 547 |
}
|
| 548 |
|
| 549 |
|
|
|
|
| 550 |
@mcp.tool()
|
| 551 |
def spotify_check_saved_tracks(track_ids: list[str]) -> dict[str, Any]:
|
| 552 |
"""Check if tracks are saved in user's library (/me/tracks/contains)."""
|
|
@@ -603,7 +641,7 @@ def spotify_remove_saved_tracks(track_ids: list[str]) -> dict[str, Any]:
|
|
| 603 |
|
| 604 |
@mcp.tool()
|
| 605 |
def spotify_set_tracks_saved(track_ids: list[str], saved: bool) -> dict[str, Any]:
|
| 606 |
-
"""Set saved state for tracks in user library (
|
| 607 |
if saved:
|
| 608 |
result = spotify_save_tracks(track_ids)
|
| 609 |
result["action"] = "saved"
|
|
@@ -792,12 +830,14 @@ def spotify_delete_playlist(playlist_id: str) -> dict[str, Any]:
|
|
| 792 |
}
|
| 793 |
|
| 794 |
|
|
|
|
| 795 |
app = FastAPI(title="Spotify MCP Server", version="1.0.0")
|
| 796 |
app.mount("/gradio_api/mcp", mcp.sse_app("/gradio_api/mcp"))
|
| 797 |
|
| 798 |
|
| 799 |
@app.get("/")
|
| 800 |
def root() -> dict[str, Any]:
|
|
|
|
| 801 |
return {
|
| 802 |
"service": "spotify-mcp-server",
|
| 803 |
"auth_login_url": "/auth/login",
|
|
@@ -810,16 +850,19 @@ def root() -> dict[str, Any]:
|
|
| 810 |
|
| 811 |
@app.get("/health")
|
| 812 |
def health() -> dict[str, str]:
|
|
|
|
| 813 |
return {"status": "ok"}
|
| 814 |
|
| 815 |
|
| 816 |
@app.get("/auth/status")
|
| 817 |
def auth_status() -> dict[str, Any]:
|
|
|
|
| 818 |
return _token_status()
|
| 819 |
|
| 820 |
|
| 821 |
@app.get("/auth/reset")
|
| 822 |
def auth_reset() -> dict[str, Any]:
|
|
|
|
| 823 |
removed = False
|
| 824 |
if SPOTIFY_TOKEN_FILE.exists():
|
| 825 |
try:
|
|
@@ -835,6 +878,10 @@ def auth_reset() -> dict[str, Any]:
|
|
| 835 |
|
| 836 |
@app.get("/auth/login")
|
| 837 |
def auth_login(force: bool = Query(default=True)) -> RedirectResponse:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 838 |
try:
|
| 839 |
state = _new_oauth_state()
|
| 840 |
url = _build_auth_url(state, show_dialog=force)
|
|
@@ -849,6 +896,7 @@ def auth_callback(
|
|
| 849 |
state: str | None = Query(default=None),
|
| 850 |
error: str | None = Query(default=None),
|
| 851 |
) -> HTMLResponse:
|
|
|
|
| 852 |
if error:
|
| 853 |
raise HTTPException(status_code=400, detail=f"Spotify authorization error: {error}")
|
| 854 |
|
|
@@ -876,6 +924,7 @@ def auth_callback(
|
|
| 876 |
|
| 877 |
|
| 878 |
if __name__ == "__main__":
|
|
|
|
| 879 |
import uvicorn
|
| 880 |
|
| 881 |
port = int(os.getenv("PORT", "7860"))
|
|
|
|
| 13 |
from fastapi.responses import HTMLResponse, RedirectResponse
|
| 14 |
from mcp.server.fastmcp import FastMCP
|
| 15 |
|
| 16 |
+
# MCP server for Spotify with OAuth2 and read/write tools.
|
| 17 |
+
# This file exposes:
|
| 18 |
+
# 1) Authentication helpers and Spotify HTTP calls.
|
| 19 |
+
# 2) MCP tools registered with FastMCP.
|
| 20 |
+
# 3) FastAPI endpoints for OAuth login and health/status checks.
|
| 21 |
+
|
| 22 |
+
# Load environment variables from .env for local runs.
|
| 23 |
load_dotenv()
|
| 24 |
|
| 25 |
+
# Official Spotify base endpoints.
|
| 26 |
SPOTIFY_ACCOUNTS_BASE = "https://accounts.spotify.com"
|
| 27 |
SPOTIFY_API_BASE = "https://api.spotify.com/v1"
|
| 28 |
|
| 29 |
+
# Main OAuth client configuration.
|
| 30 |
SPOTIFY_CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID", "").strip()
|
| 31 |
SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET", "").strip()
|
| 32 |
SPOTIFY_REDIRECT_URI = os.getenv("SPOTIFY_REDIRECT_URI", "").strip()
|
|
|
|
| 59 |
"https://pharmaia-demo-mcp-server-spotify.hf.space/gradio_api/mcp/sse",
|
| 60 |
).strip()
|
| 61 |
|
| 62 |
+
# In-memory OAuth states used to mitigate CSRF on callback.
|
| 63 |
OAUTH_STATE_TTL_SECONDS = 600
|
| 64 |
_oauth_states: dict[str, int] = {}
|
| 65 |
|
| 66 |
|
| 67 |
class SpotifyAuthError(RuntimeError):
|
| 68 |
+
"""Error de autenticacion/autorizacion Spotify con mensaje apto para cliente."""
|
| 69 |
+
|
| 70 |
pass
|
| 71 |
|
| 72 |
|
| 73 |
def _require_spotify_config() -> None:
|
| 74 |
+
"""Validate that all required OAuth credentials are configured."""
|
| 75 |
missing = []
|
| 76 |
if not SPOTIFY_CLIENT_ID:
|
| 77 |
missing.append("SPOTIFY_CLIENT_ID")
|
|
|
|
| 87 |
|
| 88 |
|
| 89 |
def _cleanup_expired_states() -> None:
|
| 90 |
+
"""Remove expired OAuth states to avoid unbounded memory growth."""
|
| 91 |
now = int(time.time())
|
| 92 |
expired = [k for k, ts in _oauth_states.items() if (now - ts) > OAUTH_STATE_TTL_SECONDS]
|
| 93 |
for key in expired:
|
|
|
|
| 95 |
|
| 96 |
|
| 97 |
def _new_oauth_state() -> str:
|
| 98 |
+
"""Generate a new OAuth state and register it with current timestamp."""
|
| 99 |
_cleanup_expired_states()
|
| 100 |
state = secrets.token_urlsafe(24)
|
| 101 |
_oauth_states[state] = int(time.time())
|
|
|
|
| 103 |
|
| 104 |
|
| 105 |
def _consume_oauth_state(state: str) -> bool:
|
| 106 |
+
"""Consume a one-time OAuth state and validate it is not expired."""
|
| 107 |
_cleanup_expired_states()
|
| 108 |
ts = _oauth_states.pop(state, None)
|
| 109 |
if ts is None:
|
|
|
|
| 112 |
|
| 113 |
|
| 114 |
def _spotify_basic_auth_header() -> str:
|
| 115 |
+
"""Build Basic Auth credential (client_id:client_secret) in base64."""
|
| 116 |
token = f"{SPOTIFY_CLIENT_ID}:{SPOTIFY_CLIENT_SECRET}".encode("utf-8")
|
| 117 |
return base64.b64encode(token).decode("utf-8")
|
| 118 |
|
| 119 |
|
| 120 |
def _load_tokens() -> dict[str, Any] | None:
|
| 121 |
+
"""Read cached token file from disk; return None if missing or invalid."""
|
| 122 |
if not SPOTIFY_TOKEN_FILE.exists():
|
| 123 |
return None
|
| 124 |
try:
|
|
|
|
| 128 |
|
| 129 |
|
| 130 |
def _save_tokens(token_payload: dict[str, Any], previous: dict[str, Any] | None = None) -> dict[str, Any]:
|
| 131 |
+
"""Persist tokens on disk, preserve refresh_token, and compute expires_at."""
|
| 132 |
now = int(time.time())
|
| 133 |
expires_in = int(token_payload.get("expires_in", 3600))
|
| 134 |
|
|
|
|
| 151 |
|
| 152 |
|
| 153 |
def _is_expired(tokens: dict[str, Any]) -> bool:
|
| 154 |
+
"""Check access_token expiration with a 30-second safety margin."""
|
| 155 |
expires_at = int(tokens.get("expires_at", 0))
|
| 156 |
return int(time.time()) >= (expires_at - 30)
|
| 157 |
|
| 158 |
|
| 159 |
def _exchange_code_for_tokens(code: str) -> dict[str, Any]:
|
| 160 |
+
"""Exchange OAuth authorization code for access/refresh tokens."""
|
| 161 |
_require_spotify_config()
|
| 162 |
|
| 163 |
data = {
|
|
|
|
| 188 |
|
| 189 |
|
| 190 |
def _refresh_access_token(tokens: dict[str, Any]) -> dict[str, Any]:
|
| 191 |
+
"""Refresh access_token using refresh_token and persist the result."""
|
| 192 |
refresh_token = tokens.get("refresh_token")
|
| 193 |
if not refresh_token:
|
| 194 |
raise SpotifyAuthError(
|
|
|
|
| 222 |
|
| 223 |
|
| 224 |
def _get_valid_access_token() -> str:
|
| 225 |
+
"""Get a usable access_token; refresh automatically if expired."""
|
| 226 |
_require_spotify_config()
|
| 227 |
|
| 228 |
tokens = _load_tokens()
|
|
|
|
| 248 |
params: dict[str, Any] | None = None,
|
| 249 |
json_body: dict[str, Any] | None = None,
|
| 250 |
) -> dict[str, Any]:
|
| 251 |
+
"""Execute authenticated Spotify API request with auto-refresh on 401.
|
| 252 |
+
|
| 253 |
+
Converts Spotify HTTP errors into SpotifyAuthError with useful details.
|
| 254 |
+
"""
|
| 255 |
token = _get_valid_access_token()
|
| 256 |
headers = {"Authorization": f"Bearer {token}"}
|
| 257 |
|
|
|
|
| 307 |
|
| 308 |
|
| 309 |
def _build_auth_url(state: str, show_dialog: bool = True) -> str:
|
| 310 |
+
"""Build Spotify /authorize OAuth URL with scopes and anti-CSRF state."""
|
| 311 |
_require_spotify_config()
|
| 312 |
params = {
|
| 313 |
"response_type": "code",
|
|
|
|
| 321 |
|
| 322 |
|
| 323 |
def _token_status() -> dict[str, Any]:
|
| 324 |
+
"""Summarize current authentication/token status for quick diagnostics."""
|
| 325 |
tokens = _load_tokens() or {}
|
| 326 |
authenticated = bool(tokens.get("access_token"))
|
| 327 |
return {
|
|
|
|
| 333 |
|
| 334 |
|
| 335 |
def _token_scope_set() -> set[str]:
|
| 336 |
+
"""Return the set of scopes present in the persisted token."""
|
| 337 |
tokens = _load_tokens() or {}
|
| 338 |
scope_str = str(tokens.get("scope", "")).strip()
|
| 339 |
return {scope for scope in scope_str.split() if scope}
|
| 340 |
|
| 341 |
|
| 342 |
def _require_any_scope(required_scopes: list[str], action: str) -> None:
|
| 343 |
+
"""Validate required scopes for a sensitive read/write action."""
|
| 344 |
token_scopes = _token_scope_set()
|
| 345 |
if not token_scopes:
|
| 346 |
return
|
|
|
|
| 353 |
|
| 354 |
|
| 355 |
def _short_playlist(playlist: dict[str, Any]) -> dict[str, Any]:
|
| 356 |
+
"""Project playlist payload into the compact tool response format."""
|
| 357 |
return {
|
| 358 |
"id": playlist.get("id"),
|
| 359 |
"name": playlist.get("name"),
|
|
|
|
| 366 |
|
| 367 |
|
| 368 |
def _short_track(track: dict[str, Any]) -> dict[str, Any]:
|
| 369 |
+
"""Project track payload into the compact tool response format."""
|
| 370 |
return {
|
| 371 |
"id": track.get("id"),
|
| 372 |
"name": track.get("name"),
|
|
|
|
| 378 |
|
| 379 |
|
| 380 |
def _normalize_track_id(track_ref: str) -> str:
|
| 381 |
+
"""Normalize track ID from plain id, Spotify URI, or web URL."""
|
| 382 |
value = track_ref.strip()
|
| 383 |
if not value:
|
| 384 |
return ""
|
|
|
|
| 393 |
|
| 394 |
|
| 395 |
def _normalize_track_ids(track_ids: list[str], max_items: int = 500) -> list[str]:
|
| 396 |
+
"""Normalize, deduplicate, and validate a list of track IDs."""
|
| 397 |
normalized: list[str] = []
|
| 398 |
seen: set[str] = set()
|
| 399 |
|
|
|
|
| 412 |
|
| 413 |
|
| 414 |
def _normalize_playlist_id(playlist_ref: str) -> str:
|
| 415 |
+
"""Normalize playlist ID from plain id, Spotify URI, or web URL."""
|
| 416 |
value = playlist_ref.strip()
|
| 417 |
if not value:
|
| 418 |
return ""
|
|
|
|
| 427 |
|
| 428 |
|
| 429 |
def _chunked(items: list[str], size: int) -> list[list[str]]:
|
| 430 |
+
"""Split a list into fixed-size chunks for Spotify API limits."""
|
| 431 |
return [items[i : i + size] for i in range(0, len(items), size)]
|
| 432 |
|
| 433 |
|
| 434 |
+
# MCP registration: defines name, description, and tool usage instructions.
|
| 435 |
mcp = FastMCP(
|
| 436 |
name="Spotify MCP Server",
|
| 437 |
instructions=(
|
|
|
|
| 584 |
}
|
| 585 |
|
| 586 |
|
| 587 |
+
# MCP tools focused on playlist and library management.
|
| 588 |
@mcp.tool()
|
| 589 |
def spotify_check_saved_tracks(track_ids: list[str]) -> dict[str, Any]:
|
| 590 |
"""Check if tracks are saved in user's library (/me/tracks/contains)."""
|
|
|
|
| 641 |
|
| 642 |
@mcp.tool()
|
| 643 |
def spotify_set_tracks_saved(track_ids: list[str], saved: bool) -> dict[str, Any]:
|
| 644 |
+
"""Set saved state for tracks in user library (save/remove)."""
|
| 645 |
if saved:
|
| 646 |
result = spotify_save_tracks(track_ids)
|
| 647 |
result["action"] = "saved"
|
|
|
|
| 830 |
}
|
| 831 |
|
| 832 |
|
| 833 |
+
# Main HTTP app: mounts SSE transport for MCP clients.
|
| 834 |
app = FastAPI(title="Spotify MCP Server", version="1.0.0")
|
| 835 |
app.mount("/gradio_api/mcp", mcp.sse_app("/gradio_api/mcp"))
|
| 836 |
|
| 837 |
|
| 838 |
@app.get("/")
|
| 839 |
def root() -> dict[str, Any]:
|
| 840 |
+
"""Root endpoint with useful links and public MCP SSE URL."""
|
| 841 |
return {
|
| 842 |
"service": "spotify-mcp-server",
|
| 843 |
"auth_login_url": "/auth/login",
|
|
|
|
| 850 |
|
| 851 |
@app.get("/health")
|
| 852 |
def health() -> dict[str, str]:
|
| 853 |
+
"""Healthcheck simple para monitoreo y despliegue."""
|
| 854 |
return {"status": "ok"}
|
| 855 |
|
| 856 |
|
| 857 |
@app.get("/auth/status")
|
| 858 |
def auth_status() -> dict[str, Any]:
|
| 859 |
+
"""Expose current persisted OAuth token status."""
|
| 860 |
return _token_status()
|
| 861 |
|
| 862 |
|
| 863 |
@app.get("/auth/reset")
|
| 864 |
def auth_reset() -> dict[str, Any]:
|
| 865 |
+
"""Reset authentication by removing the local token file."""
|
| 866 |
removed = False
|
| 867 |
if SPOTIFY_TOKEN_FILE.exists():
|
| 868 |
try:
|
|
|
|
| 878 |
|
| 879 |
@app.get("/auth/login")
|
| 880 |
def auth_login(force: bool = Query(default=True)) -> RedirectResponse:
|
| 881 |
+
"""Start OAuth flow by redirecting to Spotify /authorize.
|
| 882 |
+
|
| 883 |
+
`force=true` shows consent dialog to ensure updated scopes.
|
| 884 |
+
"""
|
| 885 |
try:
|
| 886 |
state = _new_oauth_state()
|
| 887 |
url = _build_auth_url(state, show_dialog=force)
|
|
|
|
| 896 |
state: str | None = Query(default=None),
|
| 897 |
error: str | None = Query(default=None),
|
| 898 |
) -> HTMLResponse:
|
| 899 |
+
"""Handle OAuth callback: validate state, exchange code, and return HTML."""
|
| 900 |
if error:
|
| 901 |
raise HTTPException(status_code=400, detail=f"Spotify authorization error: {error}")
|
| 902 |
|
|
|
|
| 924 |
|
| 925 |
|
| 926 |
if __name__ == "__main__":
|
| 927 |
+
# Local entrypoint to run the server with Uvicorn.
|
| 928 |
import uvicorn
|
| 929 |
|
| 930 |
port = int(os.getenv("PORT", "7860"))
|