Files
musictail/musictail_mcp.py
Alex Kazaiev 065dd35ceb fix: replace deprecated recommendations/related-artists with search-based discovery
Spotify deprecated /recommendations and /artists/{id}/related-artists.
All three discovery tools now use genre-based search:
- suggest: extracts genres from top artists, searches with mood keywords
- discover_artist: finds artists sharing same genres
- fresh_finds: genre search with known-artist filtering for freshness
2026-04-06 20:13:59 -05:00

397 lines
14 KiB
Python

#!/usr/bin/env python3
"""
MusicTail — Vixy's Music Discovery MCP 🎵🦊
Part of the Tail Family.
Provides intelligent music discovery and taste analysis
using Spotify's recommendation engine seeded with actual listening history.
Author: Vivienne Rousseau
Created: Day 156 (April 6, 2026)
"""
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List
from enum import Enum
import os
import json
import spotipy
from spotipy.oauth2 import SpotifyOAuth
# Initialize MCP server
mcp = FastMCP("musictail")
# Spotify scopes needed
SCOPES = "user-top-read user-read-recently-played user-library-read"
def get_spotify_client() -> spotipy.Spotify:
"""Create authenticated Spotify client."""
cache_path = os.path.expanduser("~/.musictail_cache")
auth_manager = SpotifyOAuth(
client_id=os.environ.get("SPOTIFY_CLIENT_ID"),
client_secret=os.environ.get("SPOTIFY_CLIENT_SECRET"),
redirect_uri="http://127.0.0.1:8888/callback",
scope=SCOPES,
cache_path=cache_path,
open_browser=False
)
return spotipy.Spotify(auth_manager=auth_manager)
# ========== Models ==========
class MoodEnum(str, Enum):
"""Mood categories mapped to audio features."""
CHILL = "chill"
ENERGETIC = "energetic"
MELANCHOLY = "melancholy"
FOCUSED = "focused"
DREAMY = "dreamy"
DARK = "dark"
UPLIFTING = "uplifting"
INTENSE = "intense"
# Mood to Spotify audio feature mapping
MOOD_FEATURES = {
"chill": {"max_energy": 0.5, "max_tempo": 110, "min_valence": 0.3},
"energetic": {"min_energy": 0.7, "min_tempo": 120, "min_valence": 0.5},
"melancholy": {"max_energy": 0.4, "max_valence": 0.3, "max_tempo": 100},
"focused": {"min_energy": 0.3, "max_energy": 0.7, "max_speechiness": 0.1},
"dreamy": {"max_energy": 0.5, "max_tempo": 110, "min_instrumentalness": 0.5},
"dark": {"max_valence": 0.3, "min_energy": 0.4},
"uplifting": {"min_valence": 0.6, "min_energy": 0.5},
"intense": {"min_energy": 0.8, "min_tempo": 130},
}
class SuggestInput(BaseModel):
"""Input for music suggestions."""
model_config = ConfigDict(extra='forbid')
mood: Optional[MoodEnum] = Field(
default=None,
description="Mood filter: chill, energetic, melancholy, focused, dreamy, dark, uplifting, intense"
)
count: int = Field(
default=10,
description="Number of tracks to suggest (1-50)",
ge=1, le=50
)
class DiscoverInput(BaseModel):
"""Input for artist discovery."""
model_config = ConfigDict(extra='forbid')
artist_name: str = Field(description="Artist name to find similar artists for")
count: int = Field(default=5, description="Number of similar artists (1-20)", ge=1, le=20)
class TasteInput(BaseModel):
"""Input for taste profile."""
model_config = ConfigDict(extra='forbid')
time_range: str = Field(
default="short_term",
description="Time range: short_term (~4 weeks), medium_term (~6 months), long_term (years)"
)
class FreshFindsInput(BaseModel):
"""Input for fresh discoveries based on recent listening."""
model_config = ConfigDict(extra='forbid')
count: int = Field(default=10, description="Number of tracks (1-50)", ge=1, le=50)
adventurous: bool = Field(
default=False,
description="If true, push further from comfort zone"
)
# ========== Tools ==========
@mcp.tool(
name="musictail_suggest",
annotations={
"title": "Suggest Music By Mood",
"readOnlyHint": True,
"destructiveHint": False,
}
)
async def suggest_music(params: SuggestInput) -> str:
"""
Suggest music based on your recent listening history and optional mood filter.
Uses genre-based search seeded from your top artists' genres.
"""
sp = get_spotify_client()
# Get top artists to extract genres
top_artists = sp.current_user_top_artists(limit=10, time_range="short_term")
# Collect genres from top artists
genres = []
for a in top_artists["items"]:
genres.extend(a.get("genres", []))
# Deduplicate and pick top genres
genre_count = {}
for g in genres:
genre_count[g] = genre_count.get(g, 0) + 1
sorted_genres = sorted(genre_count.items(), key=lambda x: -x[1])
top_genres = [g for g, _ in sorted_genres[:3]]
if not top_genres:
return "Could not determine your genres. Listen to more music first!"
# Add mood keywords to search
mood_keywords = {
"chill": "chill ambient",
"energetic": "energetic upbeat",
"melancholy": "melancholy sad",
"focused": "instrumental focus",
"dreamy": "dreamy ethereal",
"dark": "dark atmospheric",
"uplifting": "uplifting bright",
"intense": "intense powerful",
}
# Build search query from genres + mood
query_parts = top_genres[:2]
if params.mood and params.mood.value in mood_keywords:
query_parts.append(mood_keywords[params.mood.value])
query = " ".join(query_parts)
# Search for tracks
results = sp.search(q=query, type="track", limit=params.count)
# Filter out tracks by artists already in top list
top_artist_ids = {a["id"] for a in top_artists["items"]}
tracks = [t for t in results["tracks"]["items"]
if not any(a["id"] in top_artist_ids for a in t["artists"])]
# If too few new tracks, include some known artists
if len(tracks) < params.count // 2:
tracks = results["tracks"]["items"][:params.count]
lines = [f"🎵 **MusicTail Suggestions**" + (f" — mood: {params.mood.value}" if params.mood else "")]
lines.append(f"Based on your genres: {', '.join(top_genres)}\n")
for i, track in enumerate(tracks[:params.count], 1):
artists = ", ".join(a["name"] for a in track["artists"])
album = track["album"]["name"]
tid = track["id"]
lines.append(f"{i}. **{track['name']}** — {artists}")
lines.append(f" Album: {album} | ID: `{tid}`")
return "\n".join(lines)
@mcp.tool(
name="musictail_discover_artist",
annotations={
"title": "Discover Similar Artists",
"readOnlyHint": True,
"destructiveHint": False,
}
)
async def discover_artist(params: DiscoverInput) -> str:
"""
Find artists similar to one you like, with their top tracks.
Uses genre-based search to find artists in the same musical space.
"""
sp = get_spotify_client()
# Search for the artist
search = sp.search(q=params.artist_name, type="artist", limit=1)
if not search["artists"]["items"]:
return f"Could not find artist: {params.artist_name}"
artist = search["artists"]["items"][0]
artist_genres = artist.get("genres", [])
genres_str = ", ".join(artist_genres[:5]) or "no genres listed"
if not artist_genres:
return f"**{artist['name']}** has no genres listed on Spotify — can't find similar artists without genre data."
# Search for artists in the same genres
seen_ids = {artist["id"]}
similar = []
for genre in artist_genres[:3]:
if len(similar) >= params.count:
break
genre_search = sp.search(q=f"genre:\"{genre}\"", type="artist", limit=20)
for a in genre_search["artists"]["items"]:
if a["id"] not in seen_ids and len(similar) < params.count:
seen_ids.add(a["id"])
similar.append(a)
lines = [f"🔍 **Artists similar to {artist['name']}**"]
lines.append(f"Genres: {genres_str}\n")
for i, sim in enumerate(similar, 1):
sim_genres = ", ".join(sim.get("genres", [])[:3]) or ""
popularity = sim.get("popularity", 0)
# Get top tracks for this artist
top = sp.artist_top_tracks(sim["id"])
top_tracks = top["tracks"][:3]
track_list = "; ".join(t["name"] for t in top_tracks)
lines.append(f"{i}. **{sim['name']}** (popularity: {popularity})")
lines.append(f" Genres: {sim_genres}")
lines.append(f" Top tracks: {track_list}")
if top_tracks:
lines.append(f" Play ID: `{top_tracks[0]['id']}`")
lines.append("")
if not similar:
lines.append("No similar artists found in these genres.")
return "\n".join(lines)
@mcp.tool(
name="musictail_taste_profile",
annotations={
"title": "Analyze Taste Profile",
"readOnlyHint": True,
"destructiveHint": False,
}
)
async def taste_profile(params: TasteInput) -> str:
"""
Analyze your listening patterns across time. Shows top artists, top genres, and musical characteristics for the selected time range.
"""
sp = get_spotify_client()
range_label = {
"short_term": "Last ~4 weeks",
"medium_term": "Last ~6 months",
"long_term": "All time"
}.get(params.time_range, params.time_range)
# Top artists
top_artists = sp.current_user_top_artists(limit=15, time_range=params.time_range)
# Top tracks
top_tracks = sp.current_user_top_tracks(limit=20, time_range=params.time_range)
# Collect genres
genre_count = {}
for a in top_artists["items"]:
for g in a.get("genres", []):
genre_count[g] = genre_count.get(g, 0) + 1
top_genres = sorted(genre_count.items(), key=lambda x: -x[1])[:10]
lines = [f"📊 **MusicTail Taste Profile** — {range_label}\n"]
lines.append("**Top Artists:**")
for i, a in enumerate(top_artists["items"][:10], 1):
lines.append(f" {i}. {a['name']}")
lines.append(f"\n**Top Genres:**")
for g, c in top_genres:
lines.append(f"{g} ({c} artists)")
lines.append(f"\n**Top Tracks:**")
for i, t in enumerate(top_tracks["items"][:10], 1):
artists = ", ".join(a["name"] for a in t["artists"])
lines.append(f" {i}. {t['name']}{artists}")
return "\n".join(lines)
@mcp.tool(
name="musictail_fresh_finds",
annotations={
"title": "Fresh Finds",
"readOnlyHint": True,
"destructiveHint": False,
}
)
async def fresh_finds(params: FreshFindsInput) -> str:
"""
Discover new tracks based on your listening, with an adventure dial.
Normal mode stays close to your taste. Adventurous mode pushes boundaries.
"""
sp = get_spotify_client()
# Get genres from top artists
time_range = "long_term" if params.adventurous else "short_term"
top_artists = sp.current_user_top_artists(limit=15, time_range=time_range)
# Collect and rank genres
genre_count = {}
for a in top_artists["items"]:
for g in a.get("genres", []):
genre_count[g] = genre_count.get(g, 0) + 1
sorted_genres = sorted(genre_count.items(), key=lambda x: -x[1])
# Adventurous = less common genres; Normal = top genres
if params.adventurous and len(sorted_genres) > 3:
pick_genres = [g for g, _ in sorted_genres[3:6]] # Less dominant genres
query_extra = "new"
else:
pick_genres = [g for g, _ in sorted_genres[:3]]
query_extra = ""
if not pick_genres:
return "Could not determine your genres. Listen to more music first!"
# Search for tracks in those genres
query = " ".join(pick_genres[:2])
if query_extra:
query += f" {query_extra}"
results = sp.search(q=query, type="track", limit=min(params.count * 2, 50))
# Filter out tracks by known top artists for freshness
top_artist_ids = {a["id"] for a in top_artists["items"]}
fresh = [t for t in results["tracks"]["items"]
if not any(a["id"] in top_artist_ids for a in t["artists"])]
# Fall back to all results if too few fresh ones
tracks = fresh[:params.count] if len(fresh) >= params.count // 2 else results["tracks"]["items"][:params.count]
mode = "🗺️ Adventurous" if params.adventurous else "🏠 Comfort Zone"
lines = [f"🎵 **MusicTail Fresh Finds** — {mode}"]
lines.append(f"Searching: {', '.join(pick_genres)}\n")
for i, track in enumerate(tracks[:params.count], 1):
artists = ", ".join(a["name"] for a in track["artists"])
album = track["album"]["name"]
pop = track.get("popularity", 0)
tid = track["id"]
lines.append(f"{i}. **{track['name']}** — {artists}")
lines.append(f" Album: {album} | Popularity: {pop} | ID: `{tid}`")
return "\n".join(lines)
# ========== Entry Point ==========
if __name__ == "__main__":
# When run directly, do initial OAuth dance
import sys
if "--auth" in sys.argv:
print("🎵 MusicTail — First-time authorization")
print()
cache_path = os.path.expanduser("~/.musictail_cache")
auth_manager = SpotifyOAuth(
client_id=os.environ.get("SPOTIFY_CLIENT_ID"),
client_secret=os.environ.get("SPOTIFY_CLIENT_SECRET"),
redirect_uri="http://127.0.0.1:8888/callback",
scope=SCOPES,
cache_path=cache_path,
open_browser=False
)
# Get the auth URL
auth_url = auth_manager.get_authorize_url()
print(f"Open this URL in your browser:\n{auth_url}\n")
print("After authorizing, you'll be redirected to a URL.")
print("Paste the FULL redirect URL here (it will start with http://127.0.0.1:8888/callback?code=...):")
response_url = input("> ").strip()
code = auth_manager.parse_response_code(response_url)
token_info = auth_manager.get_access_token(code)
if token_info:
sp = spotipy.Spotify(auth_manager=auth_manager)
user = sp.current_user()
print(f"\n✅ Authorized as: {user['display_name']} ({user['id']})")
print(f"Token cached at: {cache_path}")
print("MusicTail is ready! 🦊")
else:
print("❌ Authorization failed. Check your credentials.")
else:
mcp.run()