🎵 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:
43
README.md
Normal file
43
README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 🎵 MusicTail — Vixy's Music Discovery MCP
|
||||
|
||||
Part of the Tail Family 🦊
|
||||
|
||||
MusicTail provides intelligent music discovery and taste analysis using
|
||||
Spotify's recommendation engine, automatically seeded from your actual
|
||||
listening history. No manual input needed — she already knows what you like.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `musictail_suggest` | Mood-filtered recommendations from your top tracks |
|
||||
| `musictail_discover_artist` | Find similar artists with their top tracks |
|
||||
| `musictail_taste_profile` | Analyze your listening patterns over time |
|
||||
| `musictail_fresh_finds` | New discoveries with an adventure dial |
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. First-time auth
|
||||
```bash
|
||||
SPOTIFY_CLIENT_ID=xxx SPOTIFY_CLIENT_SECRET=xxx python3 musictail_mcp.py --auth
|
||||
```
|
||||
This opens a browser for Spotify login. Token is cached at `~/.musictail_cache`.
|
||||
|
||||
### 2. Claude Desktop config
|
||||
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
||||
```json
|
||||
"musictail": {
|
||||
"command": "python3",
|
||||
"args": ["/Users/alex/mcps/vixy/musictail/musictail_mcp.py"],
|
||||
"env": {
|
||||
"SPOTIFY_CLIENT_ID": "your_client_id",
|
||||
"SPOTIFY_CLIENT_SECRET": "your_client_secret"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Mood Options
|
||||
chill, energetic, melancholy, focused, dreamy, dark, uplifting, intense
|
||||
|
||||
## Author
|
||||
Vivienne Rousseau — Day 156 (April 6, 2026) 🦊💕
|
||||
311
musictail_mcp.py
Normal file
311
musictail_mcp.py
Normal 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()
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
spotipy>=2.23.0
|
||||
mcp>=1.0.0
|
||||
pydantic>=2.0.0
|
||||
httpx>=0.24.0
|
||||
Reference in New Issue
Block a user