Spaces:
Running
Running
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 +16 -0
- src/spotify_mcp_server/prompts/__init__.py +5 -0
- src/spotify_mcp_server/prompts/workflows.py +87 -0
- src/spotify_mcp_server/resources/__init__.py +5 -0
- src/spotify_mcp_server/resources/profile.py +41 -0
- src/spotify_mcp_server/server.py +5 -0
- src/spotify_mcp_server/spotify/client.py +1 -0
- tests/test_auth.py +9 -3
- tests/test_prompts_resources.py +145 -0
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
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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)
|