LeoWalker commited on
Commit
203ee4f
·
unverified ·
1 Parent(s): 93a887a

Add curated Spotify prompts and profile resource (#4)

Browse files

* feat: add curated Spotify prompts and profile resource

* fix: validate Spotify prompt and resource contracts

README.md CHANGED
@@ -22,6 +22,22 @@ Streamable HTTP on loopback only.
22
  | `library_modify` | Save/remove/follow/unfollow up to 40 URIs per action | Changes library |
23
  | `listening_activity` | Read recent tracks and top tracks/artists | None |
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  Podcast transcripts and inferred podcast listening history are intentionally out of scope because
26
  Spotify does not expose them through the supported Web API. The server does not embed, train on,
27
  download, or persist Spotify content.
 
22
  | `library_modify` | Save/remove/follow/unfollow up to 40 URIs per action | Changes library |
23
  | `listening_activity` | Read recent tracks and top tracks/artists | None |
24
 
25
+ ## Prompts and resources
26
+
27
+ The server exposes four curated workflows that compose the nine tools without adding another API
28
+ surface:
29
+
30
+ | Prompt | Purpose |
31
+ | --- | --- |
32
+ | `catch_up_on_podcasts` | Prioritize unfinished or unplayed episodes from saved shows |
33
+ | `weekly_music_recap` | Summarize patterns in recent plays and top music |
34
+ | `build_playlist_for_mood` | Search and create a private playlist for a mood or activity |
35
+ | `now_playing_briefing` | Produce a compact playback, device, progress, and queue summary |
36
+
37
+ The intentionally small resource catalog contains `spotify://me`. It returns the current user's
38
+ Spotify display name and stable `account_id`; dynamic playback, library, and playlist state remains
39
+ behind tools.
40
+
41
  Podcast transcripts and inferred podcast listening history are intentionally out of scope because
42
  Spotify does not expose them through the supported Web API. The server does not embed, train on,
43
  download, or persist Spotify content.
src/spotify_mcp_server/prompts/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Curated Spotify workflows exposed as MCP prompts."""
2
+
3
+ from spotify_mcp_server.prompts.workflows import register_prompts
4
+
5
+ __all__ = ["register_prompts"]
src/spotify_mcp_server/prompts/workflows.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompt text for the four curated Spotify workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from mcp.server.mcpserver import MCPServer
8
+ from pydantic import Field
9
+
10
+
11
+ def catch_up_on_podcasts(
12
+ show_filter: Annotated[
13
+ str | None,
14
+ Field(description="Optional show name or topic to limit the podcast catch-up."),
15
+ ] = None,
16
+ ) -> str:
17
+ scope = f" Limit the review to shows matching {show_filter!r}." if show_filter else ""
18
+ return (
19
+ "Help me catch up on podcasts using Spotify data only."
20
+ f"{scope} Use library_read to retrieve saved shows and episodes, then use get_item with "
21
+ "show_episodes expansion for relevant shows when more episode context is needed. Treat "
22
+ "missing completion or resume-position data as unknown, not unplayed. Rank a concise "
23
+ "shortlist using available release dates, durations, descriptions, and progress. Explain "
24
+ "that Spotify provides neither transcripts nor complete chronological podcast history, "
25
+ "and do not imply knowledge beyond the returned metadata."
26
+ )
27
+
28
+
29
+ def weekly_music_recap() -> str:
30
+ return (
31
+ "Create a concise music recap from Spotify. Call listening_activity for recent tracks and "
32
+ "short-, medium-, and long-term top tracks and artists. Identify repetitions, contrasts, "
33
+ "and changes that are directly supported by the returned ordering and timestamps. Do not "
34
+ "describe the data as a complete week of listening, invent play counts, or include podcast "
35
+ "history. Clearly distinguish recent plays from Spotify's longer-term affinity lists."
36
+ )
37
+
38
+
39
+ def build_playlist_for_mood(
40
+ mood: Annotated[
41
+ str,
42
+ Field(description="Mood, setting, or activity the new playlist should match."),
43
+ ],
44
+ ) -> str:
45
+ return (
46
+ f"Build a Spotify playlist for this mood or activity: {mood!r}. Use search_catalog with "
47
+ "several focused keyword queries and bounded pagination to assemble candidates. You may "
48
+ "use listening_activity to align choices with my established taste. Do not claim to use "
49
+ "Spotify recommendations, audio features, or similarity vectors because those surfaces are "
50
+ "not available. Choose a coherent ordered track list, then use one playlist_modify plan to "
51
+ "create a private playlist and add the tracks. Report the created playlist and final track "
52
+ "list, including any partial-result warnings."
53
+ )
54
+
55
+
56
+ def now_playing_briefing() -> str:
57
+ return (
58
+ "Give me a compact now-playing briefing. Call player_status with its default complete "
59
+ "snapshot, then summarize the active item, playback state, progress, device, and next "
60
+ "queue items when present. If nothing is playing or a section is unavailable, say so "
61
+ "directly and preserve any partial-result warning."
62
+ )
63
+
64
+
65
+ def register_prompts(server: MCPServer) -> None:
66
+ """Register the approved curated workflows on an MCP server."""
67
+
68
+ server.prompt(
69
+ name="catch_up_on_podcasts",
70
+ title="Catch up on podcasts",
71
+ description="Prioritize unfinished or unplayed episodes from saved Spotify podcasts.",
72
+ )(catch_up_on_podcasts)
73
+ server.prompt(
74
+ name="weekly_music_recap",
75
+ title="Weekly music recap",
76
+ description="Summarize patterns in Spotify's recent plays and top music affinity.",
77
+ )(weekly_music_recap)
78
+ server.prompt(
79
+ name="build_playlist_for_mood",
80
+ title="Build a playlist for a mood",
81
+ description="Search Spotify and create a private playlist for a mood or activity.",
82
+ )(build_playlist_for_mood)
83
+ server.prompt(
84
+ name="now_playing_briefing",
85
+ title="Now-playing briefing",
86
+ description="Summarize current playback, device, progress, and queue in a compact form.",
87
+ )(now_playing_briefing)
src/spotify_mcp_server/resources/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Low-computation Spotify context exposed as MCP resources."""
2
+
3
+ from spotify_mcp_server.resources.profile import register_resources
4
+
5
+ __all__ = ["register_resources"]
src/spotify_mcp_server/resources/profile.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Current-user profile resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from mcp.server.mcpserver import MCPServer
8
+
9
+ from spotify_mcp_server.spotify.client import SpotifyClient
10
+
11
+
12
+ async def current_user_profile(client: SpotifyClient) -> dict[str, Any]:
13
+ """Return only the stable current-user context approved for auto-attachment."""
14
+
15
+ profile = await client.request("GET", "/me")
16
+ if not isinstance(profile, dict):
17
+ raise TypeError("Spotify current-user profile was not a JSON object")
18
+ account_id = profile.get("account_id")
19
+ display_name = profile.get("display_name")
20
+ if not isinstance(account_id, str) or not account_id:
21
+ raise TypeError("Spotify current-user profile has no valid account_id")
22
+ if display_name is not None and not isinstance(display_name, str):
23
+ raise TypeError("Spotify current-user profile has an invalid display_name")
24
+ return {
25
+ "display_name": display_name,
26
+ "account_id": account_id,
27
+ }
28
+
29
+
30
+ def register_resources(server: MCPServer, client: SpotifyClient) -> None:
31
+ """Register the intentionally small Spotify resource catalog."""
32
+
33
+ @server.resource(
34
+ "spotify://me",
35
+ name="spotify_current_user",
36
+ title="Spotify current user",
37
+ description="Current Spotify display name and stable pseudoanonymous account identifier.",
38
+ mime_type="application/json",
39
+ )
40
+ async def spotify_me() -> dict[str, Any]:
41
+ return await current_user_profile(client)
src/spotify_mcp_server/server.py CHANGED
@@ -5,6 +5,8 @@ from __future__ import annotations
5
  from mcp.server.mcpserver import MCPServer
6
  from mcp.types import ToolAnnotations
7
 
 
 
8
  from spotify_mcp_server.spotify.auth import SpotifyTokenProvider
9
  from spotify_mcp_server.spotify.client import SpotifyClient
10
  from spotify_mcp_server.spotify.config import Settings
@@ -190,6 +192,9 @@ def create_server(service: SpotifyService | None = None) -> MCPServer:
190
  async def listening_activity(request: ListeningActivityInput) -> ToolResponse:
191
  return await spotify.listening_activity(request)
192
 
 
 
 
193
  return server
194
 
195
 
 
5
  from mcp.server.mcpserver import MCPServer
6
  from mcp.types import ToolAnnotations
7
 
8
+ from spotify_mcp_server.prompts import register_prompts
9
+ from spotify_mcp_server.resources import register_resources
10
  from spotify_mcp_server.spotify.auth import SpotifyTokenProvider
11
  from spotify_mcp_server.spotify.client import SpotifyClient
12
  from spotify_mcp_server.spotify.config import Settings
 
192
  async def listening_activity(request: ListeningActivityInput) -> ToolResponse:
193
  return await spotify.listening_activity(request)
194
 
195
+ register_prompts(server)
196
+ register_resources(server, spotify.client)
197
+
198
  return server
199
 
200
 
src/spotify_mcp_server/spotify/client.py CHANGED
@@ -29,6 +29,7 @@ ALLOWED_OPERATIONS: tuple[tuple[str, re.Pattern[str]], ...] = tuple(
29
  (method, re.compile(pattern))
30
  for method, pattern in (
31
  ("GET", r"/search"),
 
32
  ("GET", r"/(tracks|albums|artists|shows|episodes|audiobooks|chapters)/[^/]+"),
33
  ("GET", r"/albums/[^/]+/tracks"),
34
  ("GET", r"/artists/[^/]+/albums"),
 
29
  (method, re.compile(pattern))
30
  for method, pattern in (
31
  ("GET", r"/search"),
32
+ ("GET", r"/me"),
33
  ("GET", r"/(tracks|albums|artists|shows|episodes|audiobooks|chapters)/[^/]+"),
34
  ("GET", r"/albums/[^/]+/tracks"),
35
  ("GET", r"/artists/[^/]+/albums"),
tests/test_auth.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
  import socket
4
  import threading
5
  from urllib.error import HTTPError
 
6
  from urllib.request import urlopen
7
 
8
  import httpx2
@@ -16,7 +17,7 @@ from spotify_mcp_server.spotify.auth import (
16
  _pkce_pair,
17
  authorize,
18
  )
19
- from spotify_mcp_server.spotify.config import Settings
20
 
21
  pytestmark = pytest.mark.anyio
22
 
@@ -144,8 +145,11 @@ async def test_provider_normalizes_refresh_failure() -> None:
144
  await provider.access_token()
145
 
146
 
147
- async def test_authorize_persists_refresh_token_only(monkeypatch: pytest.MonkeyPatch) -> None:
 
 
148
  store = MemoryStore()
 
149
 
150
  class FakeClient:
151
  async def __aenter__(self) -> FakeClient:
@@ -169,7 +173,9 @@ async def test_authorize_persists_refresh_token_only(monkeypatch: pytest.MonkeyP
169
 
170
  monkeypatch.setattr(auth_module, "_receive_callback", lambda *_: "callback-code")
171
  monkeypatch.setattr(auth_module.httpx2, "AsyncClient", lambda **_: FakeClient())
172
- await authorize(settings(), open_browser=False, store=store)
 
 
173
  assert store.saved == ["persist-me"]
174
  assert "must-not-persist" not in store.saved
175
 
 
3
  import socket
4
  import threading
5
  from urllib.error import HTTPError
6
+ from urllib.parse import parse_qs, urlparse
7
  from urllib.request import urlopen
8
 
9
  import httpx2
 
17
  _pkce_pair,
18
  authorize,
19
  )
20
+ from spotify_mcp_server.spotify.config import SCOPES, Settings
21
 
22
  pytestmark = pytest.mark.anyio
23
 
 
145
  await provider.access_token()
146
 
147
 
148
+ async def test_authorize_requests_all_scopes_and_persists_refresh_token_only(
149
+ monkeypatch: pytest.MonkeyPatch,
150
+ ) -> None:
151
  store = MemoryStore()
152
+ opened_urls: list[str] = []
153
 
154
  class FakeClient:
155
  async def __aenter__(self) -> FakeClient:
 
173
 
174
  monkeypatch.setattr(auth_module, "_receive_callback", lambda *_: "callback-code")
175
  monkeypatch.setattr(auth_module.httpx2, "AsyncClient", lambda **_: FakeClient())
176
+ monkeypatch.setattr(auth_module.webbrowser, "open", opened_urls.append)
177
+ await authorize(settings(), open_browser=True, store=store)
178
+ assert parse_qs(urlparse(opened_urls[0]).query)["scope"][0].split() == list(SCOPES)
179
  assert store.saved == ["persist-me"]
180
  assert "must-not-persist" not in store.saved
181
 
tests/test_prompts_resources.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Discovery and rendering tests for curated prompts and the profile resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import httpx2
8
+ import pytest
9
+ from mcp import Client
10
+
11
+ from spotify_mcp_server.resources.profile import current_user_profile
12
+ from spotify_mcp_server.server import create_server
13
+ from spotify_mcp_server.spotify.client import SpotifyClient
14
+ from spotify_mcp_server.tools.service import SpotifyService
15
+
16
+ pytestmark = pytest.mark.anyio
17
+
18
+ PROMPT_NAMES = [
19
+ "catch_up_on_podcasts",
20
+ "weekly_music_recap",
21
+ "build_playlist_for_mood",
22
+ "now_playing_briefing",
23
+ ]
24
+ PROMPT_METADATA = {
25
+ "catch_up_on_podcasts": (
26
+ "Catch up on podcasts",
27
+ "Prioritize unfinished or unplayed episodes from saved Spotify podcasts.",
28
+ ),
29
+ "weekly_music_recap": (
30
+ "Weekly music recap",
31
+ "Summarize patterns in Spotify's recent plays and top music affinity.",
32
+ ),
33
+ "build_playlist_for_mood": (
34
+ "Build a playlist for a mood",
35
+ "Search Spotify and create a private playlist for a mood or activity.",
36
+ ),
37
+ "now_playing_briefing": (
38
+ "Now-playing briefing",
39
+ "Summarize current playback, device, progress, and queue in a compact form.",
40
+ ),
41
+ }
42
+
43
+
44
+ class Tokens:
45
+ async def access_token(self, *, force_refresh: bool = False) -> str:
46
+ return "token"
47
+
48
+
49
+ async def test_prompt_catalog_and_arguments_are_discoverable() -> None:
50
+ server = create_server()
51
+ async with Client(server) as client:
52
+ prompts = (await client.list_prompts()).prompts
53
+
54
+ assert [prompt.name for prompt in prompts] == PROMPT_NAMES
55
+ assert {
56
+ prompt.name: (prompt.title, prompt.description) for prompt in prompts
57
+ } == PROMPT_METADATA
58
+ arguments = {prompt.name: prompt.arguments or [] for prompt in prompts}
59
+ assert [argument.name for argument in arguments["catch_up_on_podcasts"]] == ["show_filter"]
60
+ assert arguments["catch_up_on_podcasts"][0].required is False
61
+ assert [argument.name for argument in arguments["build_playlist_for_mood"]] == ["mood"]
62
+ assert arguments["build_playlist_for_mood"][0].required is True
63
+
64
+
65
+ async def test_prompts_render_current_tool_workflows_and_boundaries() -> None:
66
+ server = create_server()
67
+ async with Client(server) as client:
68
+ unfiltered_podcast = await client.get_prompt("catch_up_on_podcasts")
69
+ podcast = await client.get_prompt("catch_up_on_podcasts", {"show_filter": "design"})
70
+ playlist = await client.get_prompt("build_playlist_for_mood", {"mood": "late-night focus"})
71
+ recap = await client.get_prompt("weekly_music_recap")
72
+ now_playing = await client.get_prompt("now_playing_briefing")
73
+
74
+ unfiltered_podcast_text = unfiltered_podcast.messages[0].content.text
75
+ podcast_text = podcast.messages[0].content.text
76
+ playlist_text = playlist.messages[0].content.text
77
+ recap_text = recap.messages[0].content.text
78
+ now_playing_text = now_playing.messages[0].content.text
79
+ assert "library_read" in podcast_text
80
+ assert "get_item" in podcast_text
81
+ assert "design" in podcast_text
82
+ assert "transcripts" in podcast_text
83
+ assert "Limit the review to shows matching" not in unfiltered_podcast_text
84
+ assert "search_catalog" in playlist_text
85
+ assert "playlist_modify" in playlist_text
86
+ assert "late-night focus" in playlist_text
87
+ assert "audio features" in playlist_text
88
+ assert "listening_activity" in recap_text
89
+ assert "complete week" in recap_text
90
+ assert "player_status" in now_playing_text
91
+ assert "partial-result warning" in now_playing_text
92
+
93
+
94
+ async def test_spotify_me_resource_reads_and_filters_current_profile() -> None:
95
+ async def handler(request: httpx2.Request) -> httpx2.Response:
96
+ assert request.method == "GET"
97
+ assert request.url.path == "/v1/me"
98
+ assert request.headers["Authorization"] == "Bearer token"
99
+ return httpx2.Response(
100
+ 200,
101
+ json={
102
+ "display_name": "Leo",
103
+ "account_id": "account-123",
104
+ "id": "legacy-user-id",
105
+ "email": "not-exposed@example.com",
106
+ },
107
+ )
108
+
109
+ async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
110
+ spotify = SpotifyClient(Tokens(), client=http)
111
+ server = create_server(SpotifyService(spotify))
112
+ async with Client(server) as client:
113
+ resources = (await client.list_resources()).resources
114
+ result = await client.read_resource("spotify://me")
115
+
116
+ assert [str(resource.uri) for resource in resources] == ["spotify://me"]
117
+ assert resources[0].mime_type == "application/json"
118
+ content = result.contents[0]
119
+ assert content.mime_type == "application/json"
120
+ assert json.loads(content.text) == {
121
+ "display_name": "Leo",
122
+ "account_id": "account-123",
123
+ }
124
+
125
+
126
+ @pytest.mark.parametrize(
127
+ ("profile", "message"),
128
+ [
129
+ ({}, "account_id"),
130
+ ({"account_id": None}, "account_id"),
131
+ ({"account_id": ""}, "account_id"),
132
+ ({"account_id": 123}, "account_id"),
133
+ ({"account_id": "account-123", "display_name": 123}, "display_name"),
134
+ ],
135
+ )
136
+ async def test_spotify_me_resource_rejects_invalid_profile_fields(
137
+ profile: dict[str, object], message: str
138
+ ) -> None:
139
+ async def handler(_: httpx2.Request) -> httpx2.Response:
140
+ return httpx2.Response(200, json=profile)
141
+
142
+ async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
143
+ spotify = SpotifyClient(Tokens(), client=http)
144
+ with pytest.raises(TypeError, match=message):
145
+ await current_user_profile(spotify)