File size: 12,366 Bytes
effc0fc
93a887a
 
52c1e0f
effc0fc
 
 
 
 
 
 
 
 
93a887a
 
203ee4f
 
effc0fc
 
 
 
 
 
 
93a887a
 
effc0fc
 
 
 
 
 
 
93a887a
 
 
 
 
 
 
 
 
 
 
 
52c1e0f
93a887a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52c1e0f
93a887a
 
 
 
 
 
 
effc0fc
 
 
 
93a887a
 
 
 
 
 
 
 
 
 
 
 
effc0fc
 
 
 
 
 
 
 
 
 
93a887a
 
effc0fc
 
 
 
 
 
 
 
 
93a887a
 
 
 
 
 
 
 
effc0fc
 
 
 
93a887a
 
 
 
 
 
 
 
 
 
 
 
effc0fc
 
93a887a
 
 
 
 
 
 
 
 
 
 
effc0fc
 
93a887a
 
 
 
 
 
 
 
 
 
 
effc0fc
 
93a887a
 
 
 
 
 
 
 
 
 
 
effc0fc
 
93a887a
 
 
 
 
 
 
 
 
 
 
effc0fc
 
93a887a
 
 
 
 
 
 
 
 
 
 
effc0fc
 
93a887a
 
 
 
 
 
 
 
 
 
 
effc0fc
 
93a887a
 
 
 
 
 
 
 
 
 
 
effc0fc
 
93a887a
 
 
 
 
 
 
 
 
 
 
effc0fc
 
93a887a
203ee4f
effc0fc
 
 
 
 
 
 
203ee4f
93a887a
 
 
effc0fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93a887a
52c1e0f
 
 
effc0fc
 
 
 
 
 
 
 
 
 
 
 
 
 
93a887a
 
 
 
 
 
52c1e0f
93a887a
 
52c1e0f
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
"""MCP v2 construction, registration, and transport startup."""

from __future__ import annotations

import os
from collections.abc import Awaitable, Callable, Sequence
from contextlib import AbstractAsyncContextManager
from typing import Any

from mcp.server.auth.provider import TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.context import ServerMiddleware
from mcp.server.mcpserver import Context, MCPServer
from mcp.types import ToolAnnotations

from spotify_mcp_server.prompts import register_prompts
from spotify_mcp_server.resources import register_resources
from spotify_mcp_server.server_auth import (
    AuthenticatedSubjectMiddleware,
    HostedSettings,
    ScalekitTokenVerifier,
    current_authenticated_subject,
)
from spotify_mcp_server.spotify.auth import AuthenticationError, SpotifyTokenProvider
from spotify_mcp_server.spotify.client import SpotifyClient
from spotify_mcp_server.spotify.config import Settings
from spotify_mcp_server.spotify.hosted import (
    HostedSpotifyServices,
    SpotifyConnectionRequired,
    SpotifyUserNotAllowed,
    register_hosted_routes,
)
from spotify_mcp_server.tools.common import ContractWarning, ToolResponse
from spotify_mcp_server.tools.models import (
    GetItemInput,
    LibraryModifyInput,
    LibraryReadInput,
    ListeningActivityInput,
    PlayerControlInput,
    PlayerStatusInput,
    PlaylistModifyInput,
    PlaylistReadInput,
    SearchCatalogInput,
)
from spotify_mcp_server.tools.service import SpotifyService

READ_ANNOTATIONS = ToolAnnotations(
    readOnlyHint=True,
    destructiveHint=False,
    idempotentHint=True,
    openWorldHint=True,
)
PLAYER_WRITE_ANNOTATIONS = ToolAnnotations(
    readOnlyHint=False,
    destructiveHint=False,
    idempotentHint=False,
    openWorldHint=True,
)
PLAYLIST_WRITE_ANNOTATIONS = ToolAnnotations(
    readOnlyHint=False,
    destructiveHint=True,
    idempotentHint=False,
    openWorldHint=True,
)
LIBRARY_WRITE_ANNOTATIONS = ToolAnnotations(
    readOnlyHint=False,
    destructiveHint=True,
    idempotentHint=True,
    openWorldHint=True,
)

ServiceResolver = Callable[[Context], Awaitable[SpotifyService]]
ResourceClientResolver = Callable[[], Awaitable[SpotifyClient]]
ServerLifespan = Callable[[MCPServer], AbstractAsyncContextManager[Any]]


def build_service(settings: Settings | None = None) -> SpotifyService:
    """Build the production service without initiating OAuth or a Spotify request."""

    resolved = settings or Settings.from_env()
    provider = SpotifyTokenProvider(resolved)
    client = SpotifyClient(
        provider, base_url=resolved.api_base_url, max_retries=resolved.max_retries
    )
    return SpotifyService(client)


def create_server(
    service: SpotifyService | None = None,
    *,
    resolve_service: ServiceResolver | None = None,
    resolve_resource_client: ResourceClientResolver | None = None,
    token_verifier: TokenVerifier | None = None,
    auth: AuthSettings | None = None,
    lifespan: ServerLifespan | None = None,
    middleware: Sequence[ServerMiddleware[Any]] | None = None,
) -> MCPServer:
    """Create the server with an injectable service for account-free protocol tests."""

    if service is not None and resolve_service is not None:
        raise ValueError("Provide either a static service or a request service resolver")
    spotify = service or (None if resolve_service is not None else build_service())

    async def resolve(_: Context) -> SpotifyService:
        assert spotify is not None
        return spotify

    service_for = resolve_service or resolve
    server = MCPServer(
        "Spotify MCP Server",
        version="0.1.0",
        instructions=(
            "Use these bundled Spotify tools to minimize conversational turns. Prefer parallel "
            "read bundles. Treat player, playlist, and library modifications as user-visible "
            "writes; preserve caller order and inspect structured warnings for partial success."
        ),
        token_verifier=token_verifier,
        auth=auth,
        lifespan=lifespan,
        middleware=middleware,
    )

    @server.tool(
        name="search_catalog",
        title="Search Spotify catalog",
        description=(
            "Search one or more Spotify catalog types in parallel. Returns native type-grouped "
            "Spotify paging objects, bounded by Spotify's current limit of 10 results per page."
        ),
        annotations=READ_ANNOTATIONS,
        structured_output=True,
    )
    async def search_catalog(request: SearchCatalogInput, ctx: Context) -> ToolResponse:
        return await _dispatch(service_for, ctx, "search_catalog", request)

    @server.tool(
        name="get_item",
        title="Get Spotify items",
        description=(
            "Fetch 1-25 heterogeneous Spotify items by ID, URI, or web URL. Optionally include "
            "natural child collections such as album tracks or show episodes in the same call."
        ),
        annotations=READ_ANNOTATIONS,
        structured_output=True,
    )
    async def get_item(request: GetItemInput, ctx: Context) -> ToolResponse:
        return await _dispatch(service_for, ctx, "get_item", request)

    @server.tool(
        name="player_status",
        title="Get Spotify player status",
        description=(
            "Fetch any combination of current playback, available devices, and the queue in "
            "parallel. Returns null current playback naturally when nothing is active."
        ),
        annotations=READ_ANNOTATIONS,
        structured_output=True,
    )
    async def player_status(request: PlayerStatusInput, ctx: Context) -> ToolResponse:
        return await _dispatch(service_for, ctx, "player_status", request)

    @server.tool(
        name="player_control",
        title="Control Spotify playback",
        description=(
            "Execute 1-20 explicitly ordered playback actions in one call, including transfer, "
            "play, pause, navigation, seek, repeat, volume, shuffle, and queue operations."
        ),
        annotations=PLAYER_WRITE_ANNOTATIONS,
        structured_output=True,
    )
    async def player_control(request: PlayerControlInput, ctx: Context) -> ToolResponse:
        return await _dispatch(service_for, ctx, "player_control", request)

    @server.tool(
        name="playlist_read",
        title="Read Spotify playlists",
        description=(
            "List the current user's playlists or fetch up to 20 referenced playlists. Playlist "
            "item contents are optional and follow Spotify's owner/collaborator access rule."
        ),
        annotations=READ_ANNOTATIONS,
        structured_output=True,
    )
    async def playlist_read(request: PlaylistReadInput, ctx: Context) -> ToolResponse:
        return await _dispatch(service_for, ctx, "playlist_read", request)

    @server.tool(
        name="playlist_modify",
        title="Modify Spotify playlists",
        description=(
            "Execute 1-20 ordered playlist creates or mutations. Later actions may reference a "
            "playlist created earlier in the same call; item actions mirror Spotify's 100-URI cap."
        ),
        annotations=PLAYLIST_WRITE_ANNOTATIONS,
        structured_output=True,
    )
    async def playlist_modify(request: PlaylistModifyInput, ctx: Context) -> ToolResponse:
        return await _dispatch(service_for, ctx, "playlist_modify", request)

    @server.tool(
        name="library_read",
        title="Read Spotify library",
        description=(
            "Bundle saved tracks, albums, shows, episodes, or audiobooks; followed artists; and "
            "heterogeneous library membership checks of up to 40 Spotify URIs."
        ),
        annotations=READ_ANNOTATIONS,
        structured_output=True,
    )
    async def library_read(request: LibraryReadInput, ctx: Context) -> ToolResponse:
        return await _dispatch(service_for, ctx, "library_read", request)

    @server.tool(
        name="library_modify",
        title="Modify Spotify library",
        description=(
            "Run 1-20 ordered save, remove, follow, or unfollow actions through Spotify's generic "
            "library endpoint. Each idempotent action accepts up to 40 typed Spotify references."
        ),
        annotations=LIBRARY_WRITE_ANNOTATIONS,
        structured_output=True,
    )
    async def library_modify(request: LibraryModifyInput, ctx: Context) -> ToolResponse:
        return await _dispatch(service_for, ctx, "library_modify", request)

    @server.tool(
        name="listening_activity",
        title="Read Spotify listening activity",
        description=(
            "Fetch recently played tracks and top tracks or artists across short-, medium-, and "
            "long-term ranges. Spotify does not expose podcast listening history here."
        ),
        annotations=READ_ANNOTATIONS,
        structured_output=True,
    )
    async def listening_activity(request: ListeningActivityInput, ctx: Context) -> ToolResponse:
        return await _dispatch(service_for, ctx, "listening_activity", request)

    register_prompts(server)

    async def static_client() -> SpotifyClient:
        assert spotify is not None
        return spotify.client

    client_for_resource = resolve_resource_client or static_client
    register_resources(server, client_for_resource)

    return server


def create_hosted_server(settings: HostedSettings) -> MCPServer:
    """Create the public server with Scalekit identity and per-user Spotify services."""

    services = HostedSpotifyServices(settings)

    async def resolve_service(ctx: Context) -> SpotifyService:
        return await services.service_for(_authenticated_subject(ctx))

    async def resolve_resource_client() -> SpotifyClient:
        return (await services.service_for(current_authenticated_subject())).client

    server = create_server(
        resolve_service=resolve_service,
        resolve_resource_client=resolve_resource_client,
        token_verifier=ScalekitTokenVerifier(settings),
        auth=settings.auth_settings(),
        lifespan=services.lifespan,
        middleware=[AuthenticatedSubjectMiddleware()],
    )
    register_hosted_routes(server, services)
    return server


async def _dispatch(
    resolve_service: ServiceResolver,
    ctx: Context,
    method: str,
    request: object,
) -> ToolResponse:
    try:
        service = await resolve_service(ctx)
        handler = getattr(service, method)
        return await handler(request)
    except AuthenticationError as exc:
        if isinstance(exc, SpotifyConnectionRequired):
            code = "spotify_authorization_required"
        elif isinstance(exc, SpotifyUserNotAllowed):
            code = "spotify_user_not_allowed"
        else:
            code = "spotify_authentication_failed"
        return ToolResponse(
            status="error",
            warnings=[ContractWarning(code=code, message=str(exc))],
        )


def _authenticated_subject(ctx: Context) -> str:
    request = ctx.request_context.request
    user = getattr(request, "user", None)
    access_token = getattr(user, "access_token", None)
    subject = getattr(access_token, "subject", None)
    if not isinstance(subject, str) or not subject:
        raise AuthenticationError("Authenticated Scalekit user subject is unavailable")
    return subject


mcp = create_server()


def main() -> None:
    """Run local loopback mode or the explicitly configured authenticated hosted mode."""

    if os.environ.get("MCP_DEPLOYMENT_MODE") == "hosted":
        hosted = HostedSettings.from_env()
        runtime = create_hosted_server(hosted)
        runtime.run(
            transport="streamable-http",
            host=hosted.host,
            port=hosted.port,
            stateless_http=True,
            json_response=True,
            transport_security=hosted.transport_security(),
        )
        return

    settings = Settings.from_env()
    if settings.host not in {"127.0.0.1", "localhost", "::1"}:
        raise ValueError("MCP_HOST must be a loopback address")
    runtime = create_server(build_service(settings))
    runtime.run(
        transport="streamable-http",
        host=settings.host,
        port=settings.port,
        stateless_http=True,
        json_response=True,
    )


if __name__ == "__main__":
    main()