Files
musictail/musictail_mcp.py

332 lines
12 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.
Automatically seeds from your top tracks — no manual input needed.
"""
sp = get_spotify_client()
# Get top tracks as seeds (mix of recent and medium-term)
top_short = sp.current_user_top_tracks(limit=5, time_range="short_term")
top_medium = sp.current_user_top_tracks(limit=5, time_range="medium_term")
seed_ids = []
for t in (top_short["items"][:3] + top_medium["items"][:2]):
if t["id"] not in seed_ids:
seed_ids.append(t["id"])
seed_ids = seed_ids[:5] # Spotify max 5 seeds
# Build recommendation kwargs
kwargs = {"seed_tracks": seed_ids, "limit": params.count}
if params.mood and params.mood.value in MOOD_FEATURES:
kwargs.update(MOOD_FEATURES[params.mood.value])
results = sp.recommendations(**kwargs)
lines = [f"🎵 **MusicTail Suggestions**" + (f" — mood: {params.mood.value}" if params.mood else "")]
lines.append(f"Seeded from your top tracks\n")
for i, track in enumerate(results["tracks"], 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.
Great for expanding from known favorites into new territory.
"""
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_id = artist["id"]
genres = ", ".join(artist.get("genres", [])[:5]) or "no genres listed"
# Get related artists
related = sp.artist_related_artists(artist_id)
similar = related["artists"][:params.count]
lines = [f"🔍 **Artists similar to {artist['name']}**"]
lines.append(f"Genres: {genres}\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("")
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()
if params.adventurous:
# Use long-term + different seed strategy
top = sp.current_user_top_artists(limit=10, time_range="long_term")
# Pick less-obvious artists (positions 5-10 instead of 1-5)
seed_artists = [a["id"] for a in top["items"][4:9]][:5]
kwargs = {
"seed_artists": seed_artists,
"limit": params.count,
"min_popularity": 5,
"max_popularity": 50, # Push toward obscure
}
else:
# Use recent tracks as seeds
top = sp.current_user_top_tracks(limit=10, time_range="short_term")
seed_tracks = [t["id"] for t in top["items"][:5]]
kwargs = {
"seed_tracks": seed_tracks,
"limit": params.count,
}
results = sp.recommendations(**kwargs)
mode = "🗺️ Adventurous" if params.adventurous else "🏠 Comfort Zone"
lines = [f"🎵 **MusicTail Fresh Finds** — {mode}\n"]
for i, track in enumerate(results["tracks"], 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()