pharmaia commited on
Commit
4a1878a
·
verified ·
1 Parent(s): 8968f43

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +51 -2
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
- # Keep OAuth states in memory for CSRF protection.
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 (alta/baja)."""
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"))