musix-box 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Spotify's Authorization Code with PKCE flow.
3
+ *
4
+ * PKCE, not the plain Authorization Code flow, because this runs in a
5
+ * Cloudflare Worker with no place to keep a client secret safe from a
6
+ * request handler that also serves public endpoints. PKCE proves the token
7
+ * request came from whoever started the authorization request without
8
+ * needing a secret at all — that is the whole point of the extension.
9
+ */
10
+
11
+ const SPOTIFY_AUTHORIZE_URL = "https://accounts.spotify.com/authorize";
12
+ const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token";
13
+
14
+ /** Scopes this package's tools need, named for what each unlocks. */
15
+ export const SPOTIFY_SCOPES = [
16
+ "user-library-read", // liked tracks, saved albums
17
+ "user-follow-read", // followed artists
18
+ "playlist-read-private", // the user's own and followed playlists
19
+ "playlist-read-collaborative",
20
+ ] as const;
21
+
22
+ /** A code_verifier: 43-128 characters from Spotify's allowed alphabet. */
23
+ const VERIFIER_ALPHABET =
24
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
25
+ const VERIFIER_LENGTH = 64;
26
+
27
+ /**
28
+ * Generate a PKCE code verifier.
29
+ *
30
+ * @returns A random string in Spotify's allowed alphabet, long enough to
31
+ * satisfy the 43-character minimum with margin.
32
+ */
33
+ export function generateCodeVerifier(): string {
34
+ const bytes = crypto.getRandomValues(new Uint8Array(VERIFIER_LENGTH));
35
+ return Array.from(bytes, (byte) => VERIFIER_ALPHABET[byte % VERIFIER_ALPHABET.length]).join(
36
+ "",
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Derive the S256 code challenge from a verifier, base64url-encoded per the
42
+ * PKCE spec (RFC 7636): standard base64, then `+`→`-`, `/`→`_`, padding
43
+ * stripped.
44
+ *
45
+ * @param verifier - The code verifier generated for this authorization.
46
+ * @returns The code challenge to send with the authorization request.
47
+ */
48
+ export async function deriveCodeChallenge(verifier: string): Promise<string> {
49
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
50
+ const bytes = new Uint8Array(digest);
51
+ let binary = "";
52
+ for (const byte of bytes) {
53
+ binary += String.fromCharCode(byte);
54
+ }
55
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
56
+ }
57
+
58
+ /**
59
+ * Build the URL to send the user to for Spotify's consent screen.
60
+ *
61
+ * @param options.clientId - This app's Spotify client ID.
62
+ * @param options.redirectUri - Must match one registered in the Spotify
63
+ * dashboard exactly.
64
+ * @param options.codeChallenge - From {@link deriveCodeChallenge}.
65
+ * @param options.state - Opaque value round-tripped to the redirect, for
66
+ * CSRF protection — the caller generates and verifies it.
67
+ * @returns The full authorize URL to redirect the user to.
68
+ */
69
+ export function buildAuthorizeUrl(options: {
70
+ clientId: string;
71
+ redirectUri: string;
72
+ codeChallenge: string;
73
+ state: string;
74
+ }): string {
75
+ const url = new URL(SPOTIFY_AUTHORIZE_URL);
76
+ url.searchParams.set("client_id", options.clientId);
77
+ url.searchParams.set("response_type", "code");
78
+ url.searchParams.set("redirect_uri", options.redirectUri);
79
+ url.searchParams.set("code_challenge_method", "S256");
80
+ url.searchParams.set("code_challenge", options.codeChallenge);
81
+ url.searchParams.set("scope", SPOTIFY_SCOPES.join(" "));
82
+ url.searchParams.set("state", options.state);
83
+ return url.toString();
84
+ }
85
+
86
+ export type SpotifyTokens = {
87
+ accessToken: string;
88
+ refreshToken: string;
89
+ /** Epoch milliseconds. Derived from `expires_in` at the moment of the call. */
90
+ expiresAt: number;
91
+ };
92
+
93
+ /**
94
+ * Exchange an authorization code for tokens.
95
+ *
96
+ * @param options.clientId - This app's Spotify client ID.
97
+ * @param options.redirectUri - Must match the one used in the authorize step.
98
+ * @param options.code - The `code` query parameter Spotify redirected back with.
99
+ * @param options.codeVerifier - The verifier generated for this authorization.
100
+ * @param options.now - Clock, injected so `expiresAt` is testable.
101
+ */
102
+ export async function exchangeCodeForTokens(options: {
103
+ clientId: string;
104
+ redirectUri: string;
105
+ code: string;
106
+ codeVerifier: string;
107
+ now: () => number;
108
+ }): Promise<SpotifyTokens> {
109
+ const response = await fetch(SPOTIFY_TOKEN_URL, {
110
+ method: "POST",
111
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
112
+ body: new URLSearchParams({
113
+ grant_type: "authorization_code",
114
+ code: options.code,
115
+ redirect_uri: options.redirectUri,
116
+ client_id: options.clientId,
117
+ code_verifier: options.codeVerifier,
118
+ }),
119
+ });
120
+
121
+ if (!response.ok) {
122
+ throw new Error(`Spotify token exchange failed: ${response.status} ${await response.text()}`);
123
+ }
124
+
125
+ const body = (await response.json()) as {
126
+ access_token: string;
127
+ refresh_token: string;
128
+ expires_in: number;
129
+ };
130
+
131
+ return {
132
+ accessToken: body.access_token,
133
+ refreshToken: body.refresh_token,
134
+ expiresAt: options.now() + body.expires_in * 1000,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Refresh an access token.
140
+ *
141
+ * Spotify's PKCE refresh may or may not return a new `refresh_token` — when
142
+ * it does not, the caller keeps using the one it already has. This function
143
+ * reflects that directly: `refreshToken` in the result is the one to store
144
+ * going forward, whether or not Spotify issued a new one.
145
+ *
146
+ * @param options.clientId - This app's Spotify client ID.
147
+ * @param options.refreshToken - The refresh token to use.
148
+ * @param options.now - Clock, injected so `expiresAt` is testable.
149
+ */
150
+ export async function refreshAccessToken(options: {
151
+ clientId: string;
152
+ refreshToken: string;
153
+ now: () => number;
154
+ }): Promise<SpotifyTokens> {
155
+ const response = await fetch(SPOTIFY_TOKEN_URL, {
156
+ method: "POST",
157
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
158
+ body: new URLSearchParams({
159
+ grant_type: "refresh_token",
160
+ refresh_token: options.refreshToken,
161
+ client_id: options.clientId,
162
+ }),
163
+ });
164
+
165
+ if (!response.ok) {
166
+ throw new Error(`Spotify token refresh failed: ${response.status} ${await response.text()}`);
167
+ }
168
+
169
+ const body = (await response.json()) as {
170
+ access_token: string;
171
+ refresh_token?: string;
172
+ expires_in: number;
173
+ };
174
+
175
+ return {
176
+ accessToken: body.access_token,
177
+ // Spotify's own documented behaviour: a refresh token is not guaranteed
178
+ // in every response, and when absent the existing one is still valid.
179
+ refreshToken: body.refresh_token ?? options.refreshToken,
180
+ expiresAt: options.now() + body.expires_in * 1000,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Whether a token is due for refresh.
186
+ *
187
+ * @param tokens - The stored tokens.
188
+ * @param options.now - Clock.
189
+ * @param options.marginMs - How long before actual expiry to refresh early,
190
+ * so a request in flight does not race the token's own expiry.
191
+ */
192
+ export function needsRefresh(
193
+ tokens: SpotifyTokens,
194
+ options: { now: () => number; marginMs: number },
195
+ ): boolean {
196
+ return options.now() >= tokens.expiresAt - options.marginMs;
197
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * `MediaProvider` implemented against the real Spotify Web API.
3
+ *
4
+ * Maps Spotify's own response shapes onto the shared interface. Two of
5
+ * Spotify's list endpoints paginate by numeric offset (saved tracks, saved
6
+ * albums, playlists, playlist tracks) and one paginates by cursor (followed
7
+ * artists) — `Page.nextCursor` is opaque on purpose so this difference never
8
+ * has to leak past this file.
9
+ */
10
+
11
+ import {
12
+ LIBRARY_MAX_LIMIT,
13
+ SEARCH_MAX_LIMIT,
14
+ SpotifyApiClient,
15
+ type SpotifyAlbum,
16
+ type SpotifyArtist,
17
+ type SpotifyPagingObject,
18
+ type SpotifyPlaylist,
19
+ type SpotifyTrack,
20
+ } from "./spotify_api_client";
21
+ import type {
22
+ Album,
23
+ Artist,
24
+ MediaProvider,
25
+ Page,
26
+ Playlist,
27
+ SearchType,
28
+ Track,
29
+ } from "../media_provider";
30
+
31
+ function toTrack(track: SpotifyTrack): Track {
32
+ return {
33
+ id: track.id,
34
+ name: track.name,
35
+ artistNames: track.artists.map((artist) => artist.name),
36
+ albumName: track.album.name,
37
+ durationMs: track.duration_ms,
38
+ uri: track.uri,
39
+ };
40
+ }
41
+
42
+ function toArtist(artist: SpotifyArtist): Artist {
43
+ return { id: artist.id, name: artist.name, genres: artist.genres, uri: artist.uri };
44
+ }
45
+
46
+ function toAlbum(album: SpotifyAlbum): Album {
47
+ return {
48
+ id: album.id,
49
+ name: album.name,
50
+ artistNames: album.artists.map((artist) => artist.name),
51
+ releaseDate: album.release_date,
52
+ uri: album.uri,
53
+ };
54
+ }
55
+
56
+ function toPlaylist(playlist: SpotifyPlaylist): Playlist {
57
+ return {
58
+ id: playlist.id,
59
+ name: playlist.name,
60
+ ownerName: playlist.owner.display_name ?? playlist.owner.id,
61
+ trackCount: playlist.tracks.total,
62
+ uri: playlist.uri,
63
+ };
64
+ }
65
+
66
+ /** Offset-paginated Spotify endpoints all share this cursor shape: the next offset, as a string. */
67
+ function offsetPage<SpotifyItem, Item>(
68
+ paging: SpotifyPagingObject<SpotifyItem>,
69
+ map: (item: SpotifyItem) => Item,
70
+ ): Page<Item> {
71
+ return {
72
+ items: paging.items.map(map),
73
+ nextCursor: paging.next === null ? null : String(paging.offset + paging.items.length),
74
+ total: paging.total,
75
+ };
76
+ }
77
+
78
+ const emptyPage = <Item>(): Page<Item> => ({ items: [], nextCursor: null, total: 0 });
79
+
80
+ export class SpotifyProvider implements MediaProvider {
81
+ readonly name = "spotify";
82
+ private readonly client: SpotifyApiClient;
83
+
84
+ constructor(accessToken: string) {
85
+ this.client = new SpotifyApiClient(accessToken);
86
+ }
87
+
88
+ async search(
89
+ query: string,
90
+ options: { types?: SearchType[]; limit?: number; cursor?: string } = {},
91
+ ): Promise<{
92
+ tracks: Page<Track>;
93
+ artists: Page<Artist>;
94
+ albums: Page<Album>;
95
+ playlists: Page<Playlist>;
96
+ }> {
97
+ const types = options.types ?? (["track", "artist", "album", "playlist"] as const);
98
+ const limit = Math.min(options.limit ?? SEARCH_MAX_LIMIT, SEARCH_MAX_LIMIT);
99
+ const offset = options.cursor ? Number(options.cursor) : 0;
100
+
101
+ const result = await this.client.search(query, [...types], { limit, offset });
102
+
103
+ return {
104
+ tracks: result.tracks ? offsetPage(result.tracks, toTrack) : emptyPage(),
105
+ artists: result.artists ? offsetPage(result.artists, toArtist) : emptyPage(),
106
+ albums: result.albums ? offsetPage(result.albums, toAlbum) : emptyPage(),
107
+ playlists: result.playlists ? offsetPage(result.playlists, toPlaylist) : emptyPage(),
108
+ };
109
+ }
110
+
111
+ async getLikedTracks(options: { limit?: number; cursor?: string } = {}): Promise<Page<Track>> {
112
+ const paging = await this.client.getSavedTracks({
113
+ limit: Math.min(options.limit ?? LIBRARY_MAX_LIMIT, LIBRARY_MAX_LIMIT),
114
+ offset: options.cursor ? Number(options.cursor) : 0,
115
+ });
116
+ return offsetPage(paging, (saved) => toTrack(saved.track));
117
+ }
118
+
119
+ async getSavedAlbums(options: { limit?: number; cursor?: string } = {}): Promise<Page<Album>> {
120
+ const paging = await this.client.getSavedAlbums({
121
+ limit: Math.min(options.limit ?? LIBRARY_MAX_LIMIT, LIBRARY_MAX_LIMIT),
122
+ offset: options.cursor ? Number(options.cursor) : 0,
123
+ });
124
+ return offsetPage(paging, (saved) => toAlbum(saved.album));
125
+ }
126
+
127
+ async getFollowedArtists(
128
+ options: { limit?: number; cursor?: string } = {},
129
+ ): Promise<Page<Artist>> {
130
+ const result = await this.client.getFollowedArtists({
131
+ limit: Math.min(options.limit ?? LIBRARY_MAX_LIMIT, LIBRARY_MAX_LIMIT),
132
+ after: options.cursor,
133
+ });
134
+ return {
135
+ items: result.artists.items.map(toArtist),
136
+ nextCursor: result.artists.cursors.after,
137
+ total: result.artists.total,
138
+ };
139
+ }
140
+
141
+ async getPlaylists(options: { limit?: number; cursor?: string } = {}): Promise<Page<Playlist>> {
142
+ const paging = await this.client.getPlaylists({
143
+ limit: Math.min(options.limit ?? LIBRARY_MAX_LIMIT, LIBRARY_MAX_LIMIT),
144
+ offset: options.cursor ? Number(options.cursor) : 0,
145
+ });
146
+ return offsetPage(paging, toPlaylist);
147
+ }
148
+
149
+ async getPlaylistTracks(
150
+ playlistId: string,
151
+ options: { limit?: number; cursor?: string } = {},
152
+ ): Promise<Page<Track>> {
153
+ const paging = await this.client.getPlaylistTracks(playlistId, {
154
+ limit: Math.min(options.limit ?? LIBRARY_MAX_LIMIT, LIBRARY_MAX_LIMIT),
155
+ offset: options.cursor ? Number(options.cursor) : 0,
156
+ });
157
+ return offsetPage(paging, (entry) => toTrack(entry.item));
158
+ }
159
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Keeping one user's Spotify access token usable across many tool calls.
3
+ *
4
+ * An access token lasts an hour; an MCP session, and the KV entry storing
5
+ * the refresh token, both outlive that easily. So every tool call must be
6
+ * able to discover "is the stored token still good, and if not, refresh it
7
+ * and store the result" — not just read a token once at session start.
8
+ *
9
+ * The refresh token itself is the thing worth keeping durable: it is what
10
+ * KV stores, keyed by Spotify user id. The access token is not stored at
11
+ * all — it is fetched fresh from `refreshAccessToken` whenever a call needs
12
+ * one and doesn't have a still-valid one cached in the (fresh, per-call)
13
+ * closure this module doesn't keep.
14
+ */
15
+
16
+ import { refreshAccessToken, type SpotifyTokens } from "./spotify_oauth";
17
+
18
+ const REFRESH_MARGIN_MS = 60_000;
19
+
20
+ /** What is actually persisted in KV, and its own record of freshness. */
21
+ type StoredSession = {
22
+ refreshToken: string;
23
+ accessToken: string;
24
+ expiresAt: number;
25
+ };
26
+
27
+ function kvKey(spotifyUserId: string): string {
28
+ return `spotify-session:${spotifyUserId}`;
29
+ }
30
+
31
+ /**
32
+ * Store the tokens from a fresh authorization or refresh.
33
+ *
34
+ * @param kv - The `SPOTIFY_TOKENS` namespace.
35
+ * @param spotifyUserId - Whose session this is.
36
+ * @param tokens - The tokens to store.
37
+ */
38
+ export async function storeSession(
39
+ kv: KVNamespace,
40
+ spotifyUserId: string,
41
+ tokens: SpotifyTokens,
42
+ ): Promise<void> {
43
+ const stored: StoredSession = {
44
+ refreshToken: tokens.refreshToken,
45
+ accessToken: tokens.accessToken,
46
+ expiresAt: tokens.expiresAt,
47
+ };
48
+ await kv.put(kvKey(spotifyUserId), JSON.stringify(stored));
49
+ }
50
+
51
+ /** Thrown when a caller has no stored session — they need to authorize first. */
52
+ export class NoSpotifySessionError extends Error {
53
+ constructor(spotifyUserId: string) {
54
+ super(
55
+ `No stored Spotify session for ${spotifyUserId}. Authorize this server `
56
+ + "with Spotify first.",
57
+ );
58
+ this.name = "NoSpotifySessionError";
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Get a usable access token for a user, refreshing and re-storing it first
64
+ * if the stored one is expired or close to it.
65
+ *
66
+ * @param kv - The `SPOTIFY_TOKENS` namespace.
67
+ * @param spotifyUserId - Whose session to use.
68
+ * @param options.clientId - This app's Spotify client ID, needed to refresh.
69
+ * @param options.now - Clock, injected for testability.
70
+ * @returns A valid access token.
71
+ * @throws NoSpotifySessionError - When the user has never authorized.
72
+ */
73
+ export async function getAccessToken(
74
+ kv: KVNamespace,
75
+ spotifyUserId: string,
76
+ options: { clientId: string; now: () => number },
77
+ ): Promise<string> {
78
+ const raw = await kv.get(kvKey(spotifyUserId));
79
+ if (!raw) {
80
+ throw new NoSpotifySessionError(spotifyUserId);
81
+ }
82
+ const stored = JSON.parse(raw) as StoredSession;
83
+
84
+ if (options.now() < stored.expiresAt - REFRESH_MARGIN_MS) {
85
+ return stored.accessToken;
86
+ }
87
+
88
+ const refreshed = await refreshAccessToken({
89
+ clientId: options.clientId,
90
+ refreshToken: stored.refreshToken,
91
+ now: options.now,
92
+ });
93
+ await storeSession(kv, spotifyUserId, refreshed);
94
+ return refreshed.accessToken;
95
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Spotify as the identity and data-access provider for this MCP server's
3
+ * own OAuth layer.
4
+ *
5
+ * Shaped after `other-memory`'s `github_handler.ts`, with one structural
6
+ * difference: GitHub there is only ever used to answer "who is this,"
7
+ * against a token this server never stores or reuses. Spotify here is also
8
+ * the source the tools actually call — the tokens from this exchange are
9
+ * what `spotify_session.ts` persists and refreshes for every later tool
10
+ * call, not a one-time identity check.
11
+ */
12
+
13
+ import type { AuthRequest, OAuthHelpers } from "@cloudflare/workers-oauth-provider";
14
+ import { Hono } from "hono";
15
+
16
+ import {
17
+ buildAuthorizeUrl,
18
+ deriveCodeChallenge,
19
+ exchangeCodeForTokens,
20
+ generateCodeVerifier,
21
+ } from "./providers/spotify/spotify_oauth";
22
+ import { storeSession } from "./providers/spotify/spotify_session";
23
+ import type { Env, UserProps } from "./types";
24
+
25
+ /**
26
+ * Only this Spotify account may complete the flow. Single-user by design —
27
+ * an authenticated stranger is still a stranger.
28
+ */
29
+ function assertAllowedUser(spotifyUserId: string, env: Env): void {
30
+ if (spotifyUserId !== env.ALLOWED_SPOTIFY_USER_ID) {
31
+ throw new Error(`Spotify user ${spotifyUserId} is not permitted to use this server.`);
32
+ }
33
+ }
34
+
35
+ const app = new Hono<{ Bindings: Env & { OAUTH_PROVIDER: OAuthHelpers } }>();
36
+
37
+ app.get("/authorize", async (context) => {
38
+ const oauthRequest = await context.env.OAUTH_PROVIDER.parseAuthRequest(context.req.raw);
39
+ if (!oauthRequest.clientId) {
40
+ return context.text("Invalid authorization request.", 400);
41
+ }
42
+
43
+ const codeVerifier = generateCodeVerifier();
44
+ const codeChallenge = await deriveCodeChallenge(codeVerifier);
45
+
46
+ // The verifier has to survive the round trip to Spotify and back, and
47
+ // this server keeps no server-side session between the two requests — so
48
+ // it travels inside `state`, alongside the original MCP AuthRequest, the
49
+ // same way `oauthRequest` itself does in the GitHub handler.
50
+ const state = btoa(JSON.stringify({ oauthRequest, codeVerifier }));
51
+
52
+ const redirectUri = new URL("/callback", context.req.url).href;
53
+ const authorizeUrl = buildAuthorizeUrl({
54
+ clientId: context.env.SPOTIFY_CLIENT_ID,
55
+ redirectUri,
56
+ codeChallenge,
57
+ state,
58
+ });
59
+
60
+ return Response.redirect(authorizeUrl, 302);
61
+ });
62
+
63
+ app.get("/callback", async (context) => {
64
+ const code = context.req.query("code");
65
+ const stateParam = context.req.query("state");
66
+ if (!code || !stateParam) {
67
+ return context.text("Missing code or state.", 400);
68
+ }
69
+
70
+ let oauthRequest: AuthRequest;
71
+ let codeVerifier: string;
72
+ try {
73
+ ({ oauthRequest, codeVerifier } = JSON.parse(atob(stateParam)) as {
74
+ oauthRequest: AuthRequest;
75
+ codeVerifier: string;
76
+ });
77
+ } catch {
78
+ return context.text("Invalid state.", 400);
79
+ }
80
+
81
+ const redirectUri = new URL("/callback", context.req.url).href;
82
+ const tokens = await exchangeCodeForTokens({
83
+ clientId: context.env.SPOTIFY_CLIENT_ID,
84
+ redirectUri,
85
+ code,
86
+ codeVerifier,
87
+ now: () => Date.now(),
88
+ });
89
+
90
+ const profile = await fetchSpotifyProfile(tokens.accessToken);
91
+
92
+ try {
93
+ assertAllowedUser(profile.id, context.env);
94
+ } catch (error) {
95
+ return context.text((error as Error).message, 403);
96
+ }
97
+
98
+ await storeSession(context.env.SPOTIFY_TOKENS, profile.id, tokens);
99
+
100
+ const props: UserProps = {
101
+ spotifyUserId: profile.id,
102
+ displayName: profile.display_name ?? profile.id,
103
+ };
104
+
105
+ const { redirectTo } = await context.env.OAUTH_PROVIDER.completeAuthorization({
106
+ request: oauthRequest,
107
+ userId: profile.id,
108
+ metadata: { label: props.displayName },
109
+ scope: oauthRequest.scope,
110
+ props,
111
+ });
112
+
113
+ return Response.redirect(redirectTo, 302);
114
+ });
115
+
116
+ async function fetchSpotifyProfile(
117
+ accessToken: string,
118
+ ): Promise<{ id: string; display_name: string | null }> {
119
+ const response = await fetch("https://api.spotify.com/v1/me", {
120
+ headers: { Authorization: `Bearer ${accessToken}` },
121
+ });
122
+ if (!response.ok) {
123
+ throw new Error(`Fetching the Spotify profile failed: ${response.status}`);
124
+ }
125
+ return response.json();
126
+ }
127
+
128
+ export { app as SpotifyHandler };