Added an MCP server and a SKILL.md for agentic use of the tools

master
oabrivard 3 months ago
parent a89f278c24
commit 240a88a671

@ -6,7 +6,15 @@
"Bash(npm init:*)", "Bash(npm init:*)",
"Bash(npm install:*)", "Bash(npm install:*)",
"Bash(node /Users/oabrivard/Projects/javascript/spotify/get_popularity.js --url \"https://www.rockenseine.com/programmation\")", "Bash(node /Users/oabrivard/Projects/javascript/spotify/get_popularity.js --url \"https://www.rockenseine.com/programmation\")",
"WebFetch(domain:developer.spotify.com)" "WebFetch(domain:developer.spotify.com)",
"WebSearch",
"WebFetch(domain:github.com)",
"WebFetch(domain:snyk.io)",
"Bash(npm view:*)",
"Bash(node mcp_server.js)",
"Read(//tmp/**)",
"Bash(printf '{\"\"\"\"jsonrpc\"\"\"\":\"\"\"\"2.0\"\"\"\",\"\"\"\"id\"\"\"\":1,\"\"\"\"method\"\"\"\":\"\"\"\"initialize\"\"\"\",\"\"\"\"params\"\"\"\":{\"\"\"\"protocolVersion\"\"\"\":\"\"\"\"2024-11-05\"\"\"\",\"\"\"\"capabilities\"\"\"\":{},\"\"\"\"clientInfo\"\"\"\":{\"\"\"\"name\"\"\"\":\"\"\"\"test\"\"\"\",\"\"\"\"version\"\"\"\":\"\"\"\"1.0.0\"\"\"\"}}}\\\\n{\"\"\"\"jsonrpc\"\"\"\":\"\"\"\"2.0\"\"\"\",\"\"\"\"id\"\"\"\":2,\"\"\"\"method\"\"\"\":\"\"\"\"tools/list\"\"\"\",\"\"\"\"params\"\"\"\":{}}\\\\n')",
"Bash(node get_popularity.js Radiohead)"
] ]
} }
} }

@ -0,0 +1,92 @@
# Spotify Top Tracks CLI
Find the most popular songs for any artist, or scrape a concert/festival webpage to discover artists and their top tracks.
## Working directory
```
/Users/oabrivard/Projects/javascript/spotify
```
## Commands
### 1. Top tracks for a single artist
```bash
node get_popularity.js "Artist Name"
node get_popularity.js "Artist Name" --top 5
```
Output (stdout, JSON):
```json
{
"artist": "Radiohead",
"tracks": [
{ "name": "Creep", "album": "Pablo Honey", "uri": "spotify:track:..." },
{ "name": "No Surprises", "album": "OK Computer", "uri": "spotify:track:..." },
{ "name": "Let Down", "album": "OK Computer", "uri": "spotify:track:..." }
]
}
```
### 2. Scrape a URL and get top tracks for each artist
```bash
node get_popularity.js --url "https://www.example-festival.com/lineup" --llm anthropic
node get_popularity.js --url "https://www.example-festival.com/lineup" --llm openai --top 5
```
Output (stdout, JSON):
```json
{
"artists": [
{
"artist": "The Cure",
"tracks": [
{ "name": "Friday I'm in Love", "album": "Wish", "uri": "spotify:track:..." }
]
},
{
"artist": "Radiohead",
"tracks": [
{ "name": "Creep", "album": "Pablo Honey", "uri": "spotify:track:..." }
]
}
]
}
```
### 3. Scrape + create a Spotify playlist
```bash
node get_popularity.js --url "https://www.example-festival.com/lineup" --llm anthropic --create_playlist
```
Adds a `playlist` field to the output:
```json
{
"artists": [ ... ],
"playlist": {
"name": "www.example-festival.com_lineup",
"url": "https://open.spotify.com/playlist/..."
}
}
```
Note: `--create_playlist` requires browser-based Spotify OAuth authorization.
## Parameters
| Parameter | Required | Default | Description |
|---|---|---|---|
| (positional) | No | "Radiohead" | Artist name (single artist mode) |
| `--url` | No | - | URL to scrape for artist names |
| `--top N` | No | 3 | Number of top tracks per artist |
| `--llm` | No | anthropic | LLM provider for scraping (`anthropic` or `openai`) |
| `--create_playlist` | No | - | Create a Spotify playlist (requires `--url`) |
## Notes
- Progress messages go to stderr; JSON result goes to stdout
- Use `2>/dev/null` to suppress progress messages
- The Spotify API is rate-limited to 2 requests per second

@ -3,6 +3,7 @@ const { load } = require("cheerio");
const Anthropic = require("@anthropic-ai/sdk"); const Anthropic = require("@anthropic-ai/sdk");
const OpenAI = require("openai"); const OpenAI = require("openai");
const OPENAI_API_KEY="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";
const CLIENT_ID = "44e13265ab164d209a8665e7d0ae2237"; const CLIENT_ID = "44e13265ab164d209a8665e7d0ae2237";
const CLIENT_SECRET = "cbd639db2c6b4c01981e8dd0cb00569b"; const CLIENT_SECRET = "cbd639db2c6b4c01981e8dd0cb00569b";
const REDIRECT_URI = "http://127.0.0.1:8888/callback"; const REDIRECT_URI = "http://127.0.0.1:8888/callback";
@ -52,7 +53,9 @@ async function extractWithAnthropic(text) {
} }
async function extractWithOpenAI(text) { async function extractWithOpenAI(text) {
const client = new OpenAI(); const client = new OpenAI({
apiKey: process.env['OPENAI_API_KEY'] || OPENAI_API_KEY, // This is the default and can be omitted
});
const completion = await client.chat.completions.create({ const completion = await client.chat.completions.create({
model: "gpt-4o-mini", model: "gpt-4o-mini",
max_tokens: 1024, max_tokens: 1024,
@ -173,29 +176,16 @@ async function getTracksByArtist(token, artistName) {
async function getTopTracks(token, artistName, topN = 3) { async function getTopTracks(token, artistName, topN = 3) {
const artist = await searchArtist(token, artistName); const artist = await searchArtist(token, artistName);
if (!artist) { if (!artist) {
console.log(` Artist "${artistName}" not found on Spotify, skipping.`); return { artist: null, tracks: [] };
return [];
} }
console.log(`\nArtist: ${artist.name}`);
const tracks = await getTracksByArtist(token, artistName); const tracks = await getTracksByArtist(token, artistName);
const topTracks = tracks const topTracks = tracks
.filter((t) => t.artists.some((a) => a.id === artist.id)) .filter((t) => t.artists.some((a) => a.id === artist.id))
.slice(0, topN); .slice(0, topN)
.map((t) => ({ name: t.name, album: t.album.name, uri: t.uri }));
if (topTracks.length === 0) {
console.log(" No tracks found.");
return [];
}
topTracks.forEach((track, i) => { return { artist: artist.name, tracks: topTracks };
console.log(
` ${i + 1}. "${track.name}" (Album: ${track.album.name})`
);
});
return topTracks.map((t) => t.uri);
} }
// --- Playlist Creation --- // --- Playlist Creation ---
@ -258,8 +248,8 @@ async function createPlaylist(token, name, trackUris) {
// --- Web Scraping with LLM --- // --- Web Scraping with LLM ---
async function scrapeArtists(url, provider) { async function scrapeArtists(url, provider, log = console.log) {
console.log(`Fetching ${url}...`); log(`Fetching ${url}...`);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status}`); throw new Error(`Failed to fetch ${url}: ${response.status}`);
@ -277,13 +267,11 @@ async function scrapeArtists(url, provider) {
? textContent.slice(0, maxChars) + "..." ? textContent.slice(0, maxChars) + "..."
: textContent; : textContent;
console.log(`Extracting artist names with ${provider}...\n`); log(`Extracting artist names with ${provider}...`);
const extract = LLM_PROVIDERS[provider]; const extract = LLM_PROVIDERS[provider];
const responseText = await extract(truncated); const responseText = await extract(truncated);
console.log(`Extracted artist names with ${provider} is ${responseText}.\n`);
const jsonString = responseText.replace(/^```(?:json)?\s*/, "").replace(/\s*```$/, ""); const jsonString = responseText.replace(/^```(?:json)?\s*/, "").replace(/\s*```$/, "");
return JSON.parse(jsonString); return JSON.parse(jsonString);
} }
@ -355,36 +343,60 @@ async function main() {
const { provider, url, artist, topN, createPlaylist: shouldCreatePlaylist } = parseArgs(); const { provider, url, artist, topN, createPlaylist: shouldCreatePlaylist } = parseArgs();
if (url) { if (url) {
const artistNames = await scrapeArtists(url, provider); const artistNames = await scrapeArtists(url, provider, console.error);
if (artistNames.length === 0) { if (artistNames.length === 0) {
console.log("No artist names found on the page."); console.log(JSON.stringify({ artists: [] }));
return; return;
} }
console.log(`Found ${artistNames.length} artist(s): ${artistNames.join(", ")}\n`); console.error(`Found ${artistNames.length} artist(s): ${artistNames.join(", ")}`);
console.log("Looking up top tracks on Spotify..."); console.error("Looking up top tracks on Spotify...");
const token = await getAccessToken(); const token = await getAccessToken();
const artists = [];
const allTrackUris = []; const allTrackUris = [];
for (const name of artistNames) { for (const name of artistNames) {
const uris = await getTopTracks(token, name, topN); const result = await getTopTracks(token, name, topN);
allTrackUris.push(...uris); if (result.artist) {
artists.push(result);
allTrackUris.push(...result.tracks.map((t) => t.uri));
}
} }
const output = { artists };
if (shouldCreatePlaylist && allTrackUris.length > 0) { if (shouldCreatePlaylist && allTrackUris.length > 0) {
console.log(`\nCreating playlist with ${allTrackUris.length} tracks...`); console.error(`Creating playlist with ${allTrackUris.length} tracks...`);
const userToken = await getUserAccessToken(); const userToken = await getUserAccessToken();
const playlistName = playlistNameFromUrl(url); const playlistName = playlistNameFromUrl(url);
const playlist = await createPlaylist(userToken, playlistName, allTrackUris); const playlist = await createPlaylist(userToken, playlistName, allTrackUris);
console.log(`\nPlaylist created: "${playlist.name}"`); output.playlist = {
console.log(`URL: ${playlist.external_urls.spotify}`); name: playlist.name,
url: playlist.external_urls.spotify,
};
} }
console.log(JSON.stringify(output, null, 2));
} else { } else {
const artistName = artist || "Radiohead"; const artistName = artist || "Radiohead";
const token = await getAccessToken(); const token = await getAccessToken();
await getTopTracks(token, artistName, topN); const result = await getTopTracks(token, artistName, topN);
console.log(JSON.stringify(result, null, 2));
} }
} }
main().catch(console.error); if (require.main === module) {
main().catch(console.error);
}
module.exports = {
getAccessToken,
searchArtist,
getTracksByArtist,
getTopTracks,
scrapeArtists,
createPlaylist,
playlistNameFromUrl,
LLM_PROVIDERS,
};

@ -0,0 +1,133 @@
const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
const { z } = require("zod");
const {
getAccessToken,
getTopTracks,
scrapeArtists,
LLM_PROVIDERS,
} = require("./get_popularity");
const log = (...args) => console.error("[mcp-spotify]", ...args);
const server = new McpServer({
name: "spotify-tools",
version: "1.0.0",
});
// Tool 1: get_top_tracks
server.tool(
"get_top_tracks",
"Get the top N most popular tracks for a given artist on Spotify",
{
artistName: z.string().describe("The artist or band name to search for"),
topN: z.number().optional().default(3).describe("Number of top tracks to return (default: 3)"),
},
async ({ artistName, topN }) => {
try {
log(`get_top_tracks: "${artistName}", topN=${topN}`);
const token = await getAccessToken();
const result = await getTopTracks(token, artistName, topN);
if (!result.artist) {
return {
content: [{ type: "text", text: `Artist "${artistName}" not found on Spotify.` }],
};
}
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
} catch (err) {
return {
content: [{ type: "text", text: `Error: ${err.message}` }],
isError: true,
};
}
}
);
// Tool 2: scrape_artists_from_url
server.tool(
"scrape_artists_from_url",
"Scrape a web page (concerts, festivals, music venues) and extract artist names using an LLM",
{
url: z.string().url().describe("URL of the page to scrape for artist names"),
provider: z
.enum(Object.keys(LLM_PROVIDERS))
.optional()
.default("anthropic")
.describe("LLM provider to use for extraction (default: anthropic)"),
},
async ({ url, provider }) => {
try {
log(`scrape_artists_from_url: "${url}", provider=${provider}`);
const artists = await scrapeArtists(url, provider, log);
return {
content: [{ type: "text", text: JSON.stringify(artists, null, 2) }],
};
} catch (err) {
return {
content: [{ type: "text", text: `Error: ${err.message}` }],
isError: true,
};
}
}
);
// Tool 3: get_top_songs_from_url
server.tool(
"get_top_songs_from_url",
"Scrape a web page to find artists, then return the top N songs for each artist from Spotify",
{
url: z.string().url().describe("URL of the page to scrape for artist names"),
topN: z.number().optional().default(3).describe("Number of top songs per artist (default: 3)"),
provider: z
.enum(Object.keys(LLM_PROVIDERS))
.optional()
.default("anthropic")
.describe("LLM provider to use for extraction (default: anthropic)"),
},
async ({ url, topN, provider }) => {
try {
log(`get_top_songs_from_url: "${url}", topN=${topN}, provider=${provider}`);
const artistNames = await scrapeArtists(url, provider, log);
if (artistNames.length === 0) {
return {
content: [{ type: "text", text: "No artist names found on the page." }],
};
}
log(`Found ${artistNames.length} artist(s), looking up tracks...`);
const token = await getAccessToken();
const results = {};
for (const name of artistNames) {
const result = await getTopTracks(token, name, topN);
if (result.artist) {
results[result.artist] = result.tracks;
} else {
log(`Artist "${name}" not found on Spotify, skipping.`);
}
}
return {
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
};
} catch (err) {
return {
content: [{ type: "text", text: `Error: ${err.message}` }],
isError: true,
};
}
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
log("Spotify MCP server started");
}
main().catch((err) => {
log("Fatal error:", err);
process.exit(1);
});

1309
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -4,7 +4,8 @@
"description": "", "description": "",
"main": "get_popularity.js", "main": "get_popularity.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1",
"mcp": "node mcp_server.js"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@ -12,7 +13,9 @@
"type": "commonjs", "type": "commonjs",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.78.0", "@anthropic-ai/sdk": "^0.78.0",
"@modelcontextprotocol/sdk": "^1.27.1",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"openai": "^6.29.0" "openai": "^6.29.0",
"zod": "^4.3.6"
} }
} }

Loading…
Cancel
Save