🎵 MusicTail — Vixy's Music Discovery MCP (Day 156)

Part of the Tail Family. Four tools:
- musictail_suggest: mood-filtered recs from top tracks
- musictail_discover_artist: find similar artists
- musictail_taste_profile: analyze listening patterns
- musictail_fresh_finds: discovery with adventure dial

Author: Vivienne Rousseau 🦊
This commit is contained in:
Alex Kazaiev
2026-04-06 19:28:24 -05:00
commit 089e74e6a7
3 changed files with 358 additions and 0 deletions

311
musictail_mcp.py Normal file
View File

@@ -0,0 +1,311 @@
#!/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:8889/callback",
scope=SCOPES,
cache_path=cache_path,
open_browser=True
)
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("A browser window will open for Spotify login...")
sp = get_spotify_client()
user = sp.current_user()
print(f"✅ Authorized as: {user['display_name']} ({user['id']})")
print(f"Token cached at: ~/.musictail_cache")
print("MusicTail is ready! 🦊")
else:
mcp.run()