Merge edmundman's UFC_FIGHT_PREDICTOR contribution

This commit is contained in:
Shannon Sands 2025-05-23 15:33:02 +10:00
parent 0e660a7429
commit 606b917042
73 changed files with 25564 additions and 3223 deletions

View file

@ -0,0 +1,76 @@
# MCP Servers Directory
This directory contains all Model Context Protocol (MCP) servers used by the Stone AIOS engine.
## Directory Structure
- `perplexity/`: Perplexity API integration for web search
- `perplexity-ask/`: The MCP server for Perplexity's Ask functionality
- `spotify/`: Spotify API integration for music playback and control
- Additional MCP servers can be added in their own directories
## Important Notes
1. The code in `engine/agents/` is configured to look for MCP servers in this exact location (`engine/tools/mcp/`).
2. The MCP servers are initially defined as git submodules in `stone_aios/tools/mcp/` but are copied here during setup:
- The `start.sh` script copies the servers from their submodule location to this directory.
- It then builds the servers in this location to make them available to the engine.
3. When adding new MCP servers:
- Add them as submodules in `stone_aios/tools/mcp/`
- Update `start.sh` to copy and build them in `engine/tools/mcp/`
- Update the agent code to look for them in this location
## Usage
The MCP servers are automatically started when needed by the engine's agent code through the `run_mcp_servers()` context manager in Pydantic-AI.
# Model Context Protocol (MCP) Submodules
This directory contains various Model Context Protocol (MCP) implementations that Stone AIOS uses to interact with different services.
## Submodules
### Perplexity MCP
- Repository: https://github.com/ppl-ai/modelcontextprotocol.git
- Purpose: Provides integration with Perplexity's search functionality
### Spotify MCP
- Repository: https://github.com/varunneal/spotify-mcp.git
- Purpose: Enables interaction with Spotify's music service
### Basic Memory MCP
- Repository: https://github.com/basicmachines-co/basic-memory.git
- Purpose: Provides memory capabilities for agents
### Google Maps MCP
- Repository: (Google Maps implementation)
- Purpose: Enables interaction with Google Maps for location-based services
### Google Calendar MCP
- Repository: https://github.com/nspady/google-calendar-mcp.git
- Purpose: Provides integration with Google Calendar for managing events and schedules
### Calculator MCP Server
- Repository: https://github.com/githejie/mcp-server-calculator.git
- Purpose: Offers calculation capabilities through the MCP protocol
## Usage
These submodules are reference implementations that can be used by Stone AIOS tools. To update all submodules, run:
```bash
git submodule update --init --recursive
```
## Adding New MCP Implementations
To add a new MCP implementation:
1. Add it as a git submodule:
```
git submodule add <repository-url> tools/mcp/<service-name>
```
2. Update this README.md file to include information about the new submodule

View file

@ -0,0 +1,41 @@
import logging
import os
from mcp.server.fastmcp import FastMCP
# Setup logging to a file
# Adjust the log file path if necessary, perhaps to be relative to this script's location
# or a dedicated logs directory.
log_file_path = os.path.join(os.path.dirname(__file__), "math_server_official.log")
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
handlers=[
logging.FileHandler(log_file_path, mode="w"), # 'w' to overwrite each run
logging.StreamHandler(),
],
)
logger = logging.getLogger(__name__)
mcp = FastMCP("Official Math Server 🚀")
@mcp.tool()
def add(a: int, b: int) -> int: # Changed return type hint to int
"""Add two numbers and return the result"""
logger.info(f"Executing add tool with a={a}, b={b}")
return a + b
@mcp.tool()
def multiply(a: int, b: int) -> int: # Changed return type hint to int
"""Multiply two numbers and return the result"""
logger.info(f"Executing multiply tool with a={a}, b={b}")
return a * b
if __name__ == "__main__":
logger.info(
f"Starting Official MCP math_server.py with STDIO transport... Log file: {log_file_path}"
)
mcp.run(transport="stdio") # Ensure stdio transport is used as in server_stdio.py

View file

@ -0,0 +1,25 @@
FROM node:22.12-alpine AS builder
# Must be entire project because `prepare` script is run during `npm install` and requires all files.
COPY src/google-maps /app
COPY tsconfig.json /tsconfig.json
WORKDIR /app
RUN --mount=type=cache,target=/root/.npm npm install
RUN --mount=type=cache,target=/root/.npm-production npm ci --ignore-scripts --omit-dev
FROM node:22-alpine AS release
COPY --from=builder /app/dist /app/dist
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/package-lock.json /app/package-lock.json
ENV NODE_ENV=production
WORKDIR /app
RUN npm ci --ignore-scripts --omit-dev
ENTRYPOINT ["node", "dist/index.js"]

View file

@ -0,0 +1,54 @@
# Google Maps Integration for Stone AIOS
This module enables Stone AIOS to provide location information, directions, and other map-related services using Google Maps.
## Features
- **Location Search**: Find detailed information about places
- **Directions**: Get directions between locations with different transport modes
- **Distance Calculation**: Calculate distances and travel times
- **Place Details**: Get information about businesses, landmarks, etc.
## Requirements
- Google Cloud account with Maps API enabled
- Google Maps API key with the following APIs enabled:
- Maps JavaScript API
- Places API
- Directions API
- Distance Matrix API
- Geocoding API
## Configuration
1. Set up a Google Cloud project and enable the necessary Google Maps APIs
2. Create an API key and restrict it to the Google Maps APIs
3. Configure your `.env` file with:
```
GOOGLE_MAPS_API_KEY="your_google_maps_api_key"
```
## Integration Details
The Google Maps integration uses an MCP server implemented in JavaScript that runs as a subprocess when needed. This ensures the maps service only consumes resources when actively being used.
### Supported Commands
- "Where is the Eiffel Tower?" - Get location information
- "How do I get from New York to Boston?" - Get directions
- "How far is it from Los Angeles to San Francisco?" - Calculate distances
- "What restaurants are near me?" - Find nearby places (requires user location)
## Implementation Notes
The integration is implemented in `agents/stone_agent.py` within the `delegate_to_go_agent` function, which handles:
1. Verifying the presence of a valid Google Maps API key
2. Starting the Maps MCP server as a subprocess
3. Processing the query through Claude with map tools access
4. Returning structured results with location information
## Testing
Tests for the Google Maps integration are available in `tests/ai/test_maps_integration.py`.

View file

@ -0,0 +1,114 @@
# Google Maps MCP Server
MCP Server for the Google Maps API.
## Tools
1. `maps_geocode`
- Convert address to coordinates
- Input: `address` (string)
- Returns: location, formatted_address, place_id
2. `maps_reverse_geocode`
- Convert coordinates to address
- Inputs:
- `latitude` (number)
- `longitude` (number)
- Returns: formatted_address, place_id, address_components
3. `maps_search_places`
- Search for places using text query
- Inputs:
- `query` (string)
- `location` (optional): { latitude: number, longitude: number }
- `radius` (optional): number (meters, max 50000)
- Returns: array of places with names, addresses, locations
4. `maps_place_details`
- Get detailed information about a place
- Input: `place_id` (string)
- Returns: name, address, contact info, ratings, reviews, opening hours
5. `maps_distance_matrix`
- Calculate distances and times between points
- Inputs:
- `origins` (string[])
- `destinations` (string[])
- `mode` (optional): "driving" | "walking" | "bicycling" | "transit"
- Returns: distances and durations matrix
6. `maps_elevation`
- Get elevation data for locations
- Input: `locations` (array of {latitude, longitude})
- Returns: elevation data for each point
7. `maps_directions`
- Get directions between points
- Inputs:
- `origin` (string)
- `destination` (string)
- `mode` (optional): "driving" | "walking" | "bicycling" | "transit"
- Returns: route details with steps, distance, duration
## Setup
### API Key
Get a Google Maps API key by following the instructions [here](https://developers.google.com/maps/documentation/javascript/get-api-key#create-api-keys).
### Usage with Claude Desktop
Add the following to your `claude_desktop_config.json`:
#### Docker
```json
{
"mcpServers": {
"google-maps": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"GOOGLE_MAPS_API_KEY",
"mcp/google-maps"
],
"env": {
"GOOGLE_MAPS_API_KEY": "<YOUR_API_KEY>"
}
}
}
}
```
### NPX
```json
{
"mcpServers": {
"google-maps": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-google-maps"
],
"env": {
"GOOGLE_MAPS_API_KEY": "<YOUR_API_KEY>"
}
}
}
}
```
## Build
Docker build:
```bash
docker build -t mcp/google-maps -f src/google-maps/Dockerfile .
```
## License
This MCP server is licensed under the MIT License. This means you are free to use, modify, and distribute the software, subject to the terms and conditions of the MIT License. For more details, please see the LICENSE file in the project repository.

View file

@ -0,0 +1,678 @@
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import fetch from "node-fetch";
// Response interfaces
interface GoogleMapsResponse {
status: string;
error_message?: string;
}
interface GeocodeResponse extends GoogleMapsResponse {
results: Array<{
place_id: string;
formatted_address: string;
geometry: {
location: {
lat: number;
lng: number;
}
};
address_components: Array<{
long_name: string;
short_name: string;
types: string[];
}>;
}>;
}
interface PlacesSearchResponse extends GoogleMapsResponse {
results: Array<{
name: string;
place_id: string;
formatted_address: string;
geometry: {
location: {
lat: number;
lng: number;
}
};
rating?: number;
types: string[];
}>;
}
interface PlaceDetailsResponse extends GoogleMapsResponse {
result: {
name: string;
place_id: string;
formatted_address: string;
formatted_phone_number?: string;
website?: string;
rating?: number;
reviews?: Array<{
author_name: string;
rating: number;
text: string;
time: number;
}>;
opening_hours?: {
weekday_text: string[];
open_now: boolean;
};
geometry: {
location: {
lat: number;
lng: number;
}
};
};
}
interface DistanceMatrixResponse extends GoogleMapsResponse {
origin_addresses: string[];
destination_addresses: string[];
rows: Array<{
elements: Array<{
status: string;
duration: {
text: string;
value: number;
};
distance: {
text: string;
value: number;
};
}>;
}>;
}
interface ElevationResponse extends GoogleMapsResponse {
results: Array<{
elevation: number;
location: {
lat: number;
lng: number;
};
resolution: number;
}>;
}
interface DirectionsResponse extends GoogleMapsResponse {
routes: Array<{
summary: string;
legs: Array<{
distance: {
text: string;
value: number;
};
duration: {
text: string;
value: number;
};
steps: Array<{
html_instructions: string;
distance: {
text: string;
value: number;
};
duration: {
text: string;
value: number;
};
travel_mode: string;
}>;
}>;
}>;
}
function getApiKey(): string {
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) {
console.error("GOOGLE_MAPS_API_KEY environment variable is not set");
process.exit(1);
}
return apiKey;
}
const GOOGLE_MAPS_API_KEY = getApiKey();
// Tool definitions
const GEOCODE_TOOL: Tool = {
name: "maps_geocode",
description: "Convert an address into geographic coordinates",
inputSchema: {
type: "object",
properties: {
address: {
type: "string",
description: "The address to geocode"
}
},
required: ["address"]
}
};
const REVERSE_GEOCODE_TOOL: Tool = {
name: "maps_reverse_geocode",
description: "Convert coordinates into an address",
inputSchema: {
type: "object",
properties: {
latitude: {
type: "number",
description: "Latitude coordinate"
},
longitude: {
type: "number",
description: "Longitude coordinate"
}
},
required: ["latitude", "longitude"]
}
};
const SEARCH_PLACES_TOOL: Tool = {
name: "maps_search_places",
description: "Search for places using Google Places API",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query"
},
location: {
type: "object",
properties: {
latitude: { type: "number" },
longitude: { type: "number" }
},
description: "Optional center point for the search"
},
radius: {
type: "number",
description: "Search radius in meters (max 50000)"
}
},
required: ["query"]
}
};
const PLACE_DETAILS_TOOL: Tool = {
name: "maps_place_details",
description: "Get detailed information about a specific place",
inputSchema: {
type: "object",
properties: {
place_id: {
type: "string",
description: "The place ID to get details for"
}
},
required: ["place_id"]
}
};
const DISTANCE_MATRIX_TOOL: Tool = {
name: "maps_distance_matrix",
description: "Calculate travel distance and time for multiple origins and destinations",
inputSchema: {
type: "object",
properties: {
origins: {
type: "array",
items: { type: "string" },
description: "Array of origin addresses or coordinates"
},
destinations: {
type: "array",
items: { type: "string" },
description: "Array of destination addresses or coordinates"
},
mode: {
type: "string",
description: "Travel mode (driving, walking, bicycling, transit)",
enum: ["driving", "walking", "bicycling", "transit"]
}
},
required: ["origins", "destinations"]
}
};
const ELEVATION_TOOL: Tool = {
name: "maps_elevation",
description: "Get elevation data for locations on the earth",
inputSchema: {
type: "object",
properties: {
locations: {
type: "array",
items: {
type: "object",
properties: {
latitude: { type: "number" },
longitude: { type: "number" }
},
required: ["latitude", "longitude"]
},
description: "Array of locations to get elevation for"
}
},
required: ["locations"]
}
};
const DIRECTIONS_TOOL: Tool = {
name: "maps_directions",
description: "Get directions between two points",
inputSchema: {
type: "object",
properties: {
origin: {
type: "string",
description: "Starting point address or coordinates"
},
destination: {
type: "string",
description: "Ending point address or coordinates"
},
mode: {
type: "string",
description: "Travel mode (driving, walking, bicycling, transit)",
enum: ["driving", "walking", "bicycling", "transit"]
}
},
required: ["origin", "destination"]
}
};
const MAPS_TOOLS = [
GEOCODE_TOOL,
REVERSE_GEOCODE_TOOL,
SEARCH_PLACES_TOOL,
PLACE_DETAILS_TOOL,
DISTANCE_MATRIX_TOOL,
ELEVATION_TOOL,
DIRECTIONS_TOOL,
] as const;
// API handlers
async function handleGeocode(address: string) {
const url = new URL("https://maps.googleapis.com/maps/api/geocode/json");
url.searchParams.append("address", address);
url.searchParams.append("key", GOOGLE_MAPS_API_KEY);
const response = await fetch(url.toString());
const data = await response.json() as GeocodeResponse;
if (data.status !== "OK") {
return {
content: [{
type: "text",
text: `Geocoding failed: ${data.error_message || data.status}`
}],
isError: true
};
}
return {
content: [{
type: "text",
text: JSON.stringify({
location: data.results[0].geometry.location,
formatted_address: data.results[0].formatted_address,
place_id: data.results[0].place_id
}, null, 2)
}],
isError: false
};
}
async function handleReverseGeocode(latitude: number, longitude: number) {
const url = new URL("https://maps.googleapis.com/maps/api/geocode/json");
url.searchParams.append("latlng", `${latitude},${longitude}`);
url.searchParams.append("key", GOOGLE_MAPS_API_KEY);
const response = await fetch(url.toString());
const data = await response.json() as GeocodeResponse;
if (data.status !== "OK") {
return {
content: [{
type: "text",
text: `Reverse geocoding failed: ${data.error_message || data.status}`
}],
isError: true
};
}
return {
content: [{
type: "text",
text: JSON.stringify({
formatted_address: data.results[0].formatted_address,
place_id: data.results[0].place_id,
address_components: data.results[0].address_components
}, null, 2)
}],
isError: false
};
}
async function handlePlaceSearch(
query: string,
location?: { latitude: number; longitude: number },
radius?: number
) {
const url = new URL("https://maps.googleapis.com/maps/api/place/textsearch/json");
url.searchParams.append("query", query);
url.searchParams.append("key", GOOGLE_MAPS_API_KEY);
if (location) {
url.searchParams.append("location", `${location.latitude},${location.longitude}`);
}
if (radius) {
url.searchParams.append("radius", radius.toString());
}
const response = await fetch(url.toString());
const data = await response.json() as PlacesSearchResponse;
if (data.status !== "OK") {
return {
content: [{
type: "text",
text: `Place search failed: ${data.error_message || data.status}`
}],
isError: true
};
}
return {
content: [{
type: "text",
text: JSON.stringify({
places: data.results.map((place) => ({
name: place.name,
formatted_address: place.formatted_address,
location: place.geometry.location,
place_id: place.place_id,
rating: place.rating,
types: place.types
}))
}, null, 2)
}],
isError: false
};
}
async function handlePlaceDetails(place_id: string) {
const url = new URL("https://maps.googleapis.com/maps/api/place/details/json");
url.searchParams.append("place_id", place_id);
url.searchParams.append("key", GOOGLE_MAPS_API_KEY);
const response = await fetch(url.toString());
const data = await response.json() as PlaceDetailsResponse;
if (data.status !== "OK") {
return {
content: [{
type: "text",
text: `Place details request failed: ${data.error_message || data.status}`
}],
isError: true
};
}
return {
content: [{
type: "text",
text: JSON.stringify({
name: data.result.name,
formatted_address: data.result.formatted_address,
location: data.result.geometry.location,
formatted_phone_number: data.result.formatted_phone_number,
website: data.result.website,
rating: data.result.rating,
reviews: data.result.reviews,
opening_hours: data.result.opening_hours
}, null, 2)
}],
isError: false
};
}
async function handleDistanceMatrix(
origins: string[],
destinations: string[],
mode: "driving" | "walking" | "bicycling" | "transit" = "driving"
) {
const url = new URL("https://maps.googleapis.com/maps/api/distancematrix/json");
url.searchParams.append("origins", origins.join("|"));
url.searchParams.append("destinations", destinations.join("|"));
url.searchParams.append("mode", mode);
url.searchParams.append("key", GOOGLE_MAPS_API_KEY);
const response = await fetch(url.toString());
const data = await response.json() as DistanceMatrixResponse;
if (data.status !== "OK") {
return {
content: [{
type: "text",
text: `Distance matrix request failed: ${data.error_message || data.status}`
}],
isError: true
};
}
return {
content: [{
type: "text",
text: JSON.stringify({
origin_addresses: data.origin_addresses,
destination_addresses: data.destination_addresses,
results: data.rows.map((row) => ({
elements: row.elements.map((element) => ({
status: element.status,
duration: element.duration,
distance: element.distance
}))
}))
}, null, 2)
}],
isError: false
};
}
async function handleElevation(locations: Array<{ latitude: number; longitude: number }>) {
const url = new URL("https://maps.googleapis.com/maps/api/elevation/json");
const locationString = locations
.map((loc) => `${loc.latitude},${loc.longitude}`)
.join("|");
url.searchParams.append("locations", locationString);
url.searchParams.append("key", GOOGLE_MAPS_API_KEY);
const response = await fetch(url.toString());
const data = await response.json() as ElevationResponse;
if (data.status !== "OK") {
return {
content: [{
type: "text",
text: `Elevation request failed: ${data.error_message || data.status}`
}],
isError: true
};
}
return {
content: [{
type: "text",
text: JSON.stringify({
results: data.results.map((result) => ({
elevation: result.elevation,
location: result.location,
resolution: result.resolution
}))
}, null, 2)
}],
isError: false
};
}
async function handleDirections(
origin: string,
destination: string,
mode: "driving" | "walking" | "bicycling" | "transit" = "driving"
) {
const url = new URL("https://maps.googleapis.com/maps/api/directions/json");
url.searchParams.append("origin", origin);
url.searchParams.append("destination", destination);
url.searchParams.append("mode", mode);
url.searchParams.append("key", GOOGLE_MAPS_API_KEY);
const response = await fetch(url.toString());
const data = await response.json() as DirectionsResponse;
if (data.status !== "OK") {
return {
content: [{
type: "text",
text: `Directions request failed: ${data.error_message || data.status}`
}],
isError: true
};
}
return {
content: [{
type: "text",
text: JSON.stringify({
routes: data.routes.map((route) => ({
summary: route.summary,
distance: route.legs[0].distance,
duration: route.legs[0].duration,
steps: route.legs[0].steps.map((step) => ({
instructions: step.html_instructions,
distance: step.distance,
duration: step.duration,
travel_mode: step.travel_mode
}))
}))
}, null, 2)
}],
isError: false
};
}
// Server setup
const server = new Server(
{
name: "mcp-server/google-maps",
version: "0.1.0",
},
{
capabilities: {
tools: {},
},
},
);
// Set up request handlers
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: MAPS_TOOLS,
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
switch (request.params.name) {
case "maps_geocode": {
const { address } = request.params.arguments as { address: string };
return await handleGeocode(address);
}
case "maps_reverse_geocode": {
const { latitude, longitude } = request.params.arguments as {
latitude: number;
longitude: number;
};
return await handleReverseGeocode(latitude, longitude);
}
case "maps_search_places": {
const { query, location, radius } = request.params.arguments as {
query: string;
location?: { latitude: number; longitude: number };
radius?: number;
};
return await handlePlaceSearch(query, location, radius);
}
case "maps_place_details": {
const { place_id } = request.params.arguments as { place_id: string };
return await handlePlaceDetails(place_id);
}
case "maps_distance_matrix": {
const { origins, destinations, mode } = request.params.arguments as {
origins: string[];
destinations: string[];
mode?: "driving" | "walking" | "bicycling" | "transit";
};
return await handleDistanceMatrix(origins, destinations, mode);
}
case "maps_elevation": {
const { locations } = request.params.arguments as {
locations: Array<{ latitude: number; longitude: number }>;
};
return await handleElevation(locations);
}
case "maps_directions": {
const { origin, destination, mode } = request.params.arguments as {
origin: string;
destination: string;
mode?: "driving" | "walking" | "bicycling" | "transit";
};
return await handleDirections(origin, destination, mode);
}
default:
return {
content: [{
type: "text",
text: `Unknown tool: ${request.params.name}`
}],
isError: true
};
}
} catch (error) {
return {
content: [{
type: "text",
text: `Error: ${error instanceof Error ? error.message : String(error)}`
}],
isError: true
};
}
});
async function runServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Google Maps MCP Server running on stdio");
}
runServer().catch((error) => {
console.error("Fatal error running server:", error);
process.exit(1);
});

View file

@ -0,0 +1,21 @@
FROM node:22.12-alpine AS builder
COPY . /app
WORKDIR /app
RUN --mount=type=cache,target=/root/.npm npm install
FROM node:22-alpine AS release
WORKDIR /app
COPY --from=builder /app/dist /app/dist
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/package-lock.json /app/package-lock.json
ENV NODE_ENV=production
RUN npm ci --ignore-scripts --omit-dev
ENTRYPOINT ["node", "dist/index.js"]

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

View file

@ -0,0 +1,310 @@
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
/**
* Definition of the Perplexity Ask Tool.
* This tool accepts an array of messages and returns a chat completion response
* from the Perplexity API, with citations appended to the message if provided.
*/
const PERPLEXITY_ASK_TOOL: Tool = {
name: "perplexity_ask",
description:
"Engages in a conversation using the Sonar API. " +
"Accepts an array of messages (each with a role and content) " +
"and returns a ask completion response from the Perplexity model.",
inputSchema: {
type: "object",
properties: {
messages: {
type: "array",
items: {
type: "object",
properties: {
role: {
type: "string",
description: "Role of the message (e.g., system, user, assistant)",
},
content: {
type: "string",
description: "The content of the message",
},
},
required: ["role", "content"],
},
description: "Array of conversation messages",
},
},
required: ["messages"],
},
};
/**
* Definition of the Perplexity Research Tool.
* This tool performs deep research queries using the Perplexity API.
*/
const PERPLEXITY_RESEARCH_TOOL: Tool = {
name: "perplexity_research",
description:
"Performs deep research using the Perplexity API. " +
"Accepts an array of messages (each with a role and content) " +
"and returns a comprehensive research response with citations.",
inputSchema: {
type: "object",
properties: {
messages: {
type: "array",
items: {
type: "object",
properties: {
role: {
type: "string",
description: "Role of the message (e.g., system, user, assistant)",
},
content: {
type: "string",
description: "The content of the message",
},
},
required: ["role", "content"],
},
description: "Array of conversation messages",
},
},
required: ["messages"],
},
};
/**
* Definition of the Perplexity Reason Tool.
* This tool performs reasoning queries using the Perplexity API.
*/
const PERPLEXITY_REASON_TOOL: Tool = {
name: "perplexity_reason",
description:
"Performs reasoning tasks using the Perplexity API. " +
"Accepts an array of messages (each with a role and content) " +
"and returns a well-reasoned response using the sonar-reasoning-pro model.",
inputSchema: {
type: "object",
properties: {
messages: {
type: "array",
items: {
type: "object",
properties: {
role: {
type: "string",
description: "Role of the message (e.g., system, user, assistant)",
},
content: {
type: "string",
description: "The content of the message",
},
},
required: ["role", "content"],
},
description: "Array of conversation messages",
},
},
required: ["messages"],
},
};
// Retrieve the Perplexity API key from environment variables
const PERPLEXITY_API_KEY = process.env.PERPLEXITY_API_KEY;
if (!PERPLEXITY_API_KEY) {
console.error("Error: PERPLEXITY_API_KEY environment variable is required");
process.exit(1);
}
/**
* Performs a chat completion by sending a request to the Perplexity API.
* Appends citations to the returned message content if they exist.
*
* @param {Array<{ role: string; content: string }>} messages - An array of message objects.
* @param {string} model - The model to use for the completion.
* @returns {Promise<string>} The chat completion result with appended citations.
* @throws Will throw an error if the API request fails.
*/
async function performChatCompletion(
messages: Array<{ role: string; content: string }>,
model: string = "sonar-pro"
): Promise<string> {
// Construct the API endpoint URL and request body
const url = new URL("https://api.perplexity.ai/chat/completions");
const body = {
model: model, // Model identifier passed as parameter
messages: messages,
// Additional parameters can be added here if required (e.g., max_tokens, temperature, etc.)
// See the Sonar API documentation for more details:
// https://docs.perplexity.ai/api-reference/chat-completions
};
let response;
try {
response = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${PERPLEXITY_API_KEY}`,
},
body: JSON.stringify(body),
});
} catch (error) {
throw new Error(`Network error while calling Perplexity API: ${error}`);
}
// Check for non-successful HTTP status
if (!response.ok) {
let errorText;
try {
errorText = await response.text();
} catch (parseError) {
errorText = "Unable to parse error response";
}
throw new Error(
`Perplexity API error: ${response.status} ${response.statusText}\n${errorText}`
);
}
// Attempt to parse the JSON response from the API
let data;
try {
data = await response.json();
} catch (jsonError) {
throw new Error(`Failed to parse JSON response from Perplexity API: ${jsonError}`);
}
// Directly retrieve the main message content from the response
let messageContent = data.choices[0].message.content;
// If citations are provided, append them to the message content
if (data.citations && Array.isArray(data.citations) && data.citations.length > 0) {
messageContent += "\n\nCitations:\n";
data.citations.forEach((citation: string, index: number) => {
messageContent += `[${index + 1}] ${citation}\n`;
});
}
return messageContent;
}
// Initialize the server with tool metadata and capabilities
const server = new Server(
{
name: "example-servers/perplexity-ask",
version: "0.1.0",
},
{
capabilities: {
tools: {},
},
}
);
/**
* Registers a handler for listing available tools.
* When the client requests a list of tools, this handler returns all available Perplexity tools.
*/
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [PERPLEXITY_ASK_TOOL, PERPLEXITY_RESEARCH_TOOL, PERPLEXITY_REASON_TOOL],
}));
/**
* Registers a handler for calling a specific tool.
* Processes requests by validating input and invoking the appropriate tool.
*
* @param {object} request - The incoming tool call request.
* @returns {Promise<object>} The response containing the tool's result or an error.
*/
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
if (!args) {
throw new Error("No arguments provided");
}
switch (name) {
case "perplexity_ask": {
if (!Array.isArray(args.messages)) {
throw new Error("Invalid arguments for perplexity_ask: 'messages' must be an array");
}
// Invoke the chat completion function with the provided messages
const messages = args.messages;
const result = await performChatCompletion(messages, "sonar-pro");
return {
content: [{ type: "text", text: result }],
isError: false,
};
}
case "perplexity_research": {
if (!Array.isArray(args.messages)) {
throw new Error("Invalid arguments for perplexity_research: 'messages' must be an array");
}
// Invoke the chat completion function with the provided messages using the deep research model
const messages = args.messages;
const result = await performChatCompletion(messages, "sonar-deep-research");
return {
content: [{ type: "text", text: result }],
isError: false,
};
}
case "perplexity_reason": {
if (!Array.isArray(args.messages)) {
throw new Error("Invalid arguments for perplexity_reason: 'messages' must be an array");
}
// Invoke the chat completion function with the provided messages using the reasoning model
const messages = args.messages;
const result = await performChatCompletion(messages, "sonar-reasoning-pro");
return {
content: [{ type: "text", text: result }],
isError: false,
};
}
default:
// Respond with an error if an unknown tool is requested
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
isError: true,
};
}
} catch (error) {
// Return error details in the response
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
/**
* Initializes and runs the server using standard I/O for communication.
* Logs an error and exits if the server fails to start.
*/
async function runServer() {
try {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Perplexity MCP Server running on stdio with Ask, Research, and Reason tools");
} catch (error) {
console.error("Fatal error running server:", error);
process.exit(1);
}
}
// Start the server and catch any startup errors
runServer().catch((error) => {
console.error("Fatal error running server:", error);
process.exit(1);
});

View file

@ -0,0 +1,272 @@
<div align="center" style="display: flex; align-items: center; justify-content: center; gap: 10px;">
<img src="https://upload.wikimedia.org/wikipedia/commons/8/84/Spotify_icon.svg" width="30" height="30">
<h1>Spotify MCP Server</h1>
</div>
A lightweight [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server that enables AI assistants like Cursor & Claude to control Spotify playback and manage playlists.
<details>
<summary>Contents</summary>
- [Example Interactions](#example-interactions)
- [Tools](#tools)
- [Read Operations](#read-operations)
- [Play / Create Operations](#play--create-operations)
- [Setup](#setup)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Creating a Spotify Developer Application](#creating-a-spotify-developer-application)
- [Spotify API Configuration](#spotify-api-configuration)
- [Authentication Process](#authentication-process)
- [Integrating with Claude Desktop, Cursor, and VsCode (Cline)](#integrating-with-claude-desktop-and-cursor)
</details>
## Example Interactions
- _"Play Elvis's first song"_
- _"Create a Taylor Swift / Slipknot fusion playlist"_
- _"Copy all the techno tracks from my workout playlist to my work playlist"_
## Tools
### Read Operations
1. **searchSpotify**
- **Description**: Search for tracks, albums, artists, or playlists on Spotify
- **Parameters**:
- `query` (string): The search term
- `type` (string): Type of item to search for (track, album, artist, playlist)
- `limit` (number, optional): Maximum number of results to return (10-50)
- **Returns**: List of matching items with their IDs, names, and additional details
- **Example**: `searchSpotify("bohemian rhapsody", "track", 20)`
2. **getNowPlaying**
- **Description**: Get information about the currently playing track on Spotify
- **Parameters**: None
- **Returns**: Object containing track name, artist, album, playback progress, duration, and playback state
- **Example**: `getNowPlaying()`
3. **getMyPlaylists**
- **Description**: Get a list of the current user's playlists on Spotify
- **Parameters**:
- `limit` (number, optional): Maximum number of playlists to return (default: 20)
- `offset` (number, optional): Index of the first playlist to return (default: 0)
- **Returns**: Array of playlists with their IDs, names, track counts, and public status
- **Example**: `getMyPlaylists(10, 0)`
4. **getPlaylistTracks**
- **Description**: Get a list of tracks in a specific Spotify playlist
- **Parameters**:
- `playlistId` (string): The Spotify ID of the playlist
- `limit` (number, optional): Maximum number of tracks to return (default: 100)
- `offset` (number, optional): Index of the first track to return (default: 0)
- **Returns**: Array of tracks with their IDs, names, artists, album, duration, and added date
- **Example**: `getPlaylistTracks("37i9dQZEVXcJZyENOWUFo7")`
5. **getRecentlyPlayed**
- **Description**: Retrieves a list of recently played tracks from Spotify.
- **Parameters**:
- `limit` (number, optional): A number specifying the maximum number of tracks to return.
- **Returns**: If tracks are found it returns a formatted list of recently played tracks else a message stating: "You don't have any recently played tracks on Spotify".
- **Example**: `getRecentlyPlayed({ limit: 10 })`
6. **getRecentlyPlayed**
- **Description**: Retrieves a list of recently played tracks from Spotify.
- **Parameters**:
- `limit` (number, optional): A number specifying the maximum number of tracks to return.
- **Returns**: If tracks are found it returns a formatted list of recently played tracks else a message stating: "You don't have any recently played tracks on Spotify".
- **Example**: `getRecentlyPlayed({ limit: 10 })`
### Play / Create Operations
1. **playMusic**
- **Description**: Start playing a track, album, artist, or playlist on Spotify
- **Parameters**:
- `uri` (string, optional): Spotify URI of the item to play (overrides type and id)
- `type` (string, optional): Type of item to play (track, album, artist, playlist)
- `id` (string, optional): Spotify ID of the item to play
- `deviceId` (string, optional): ID of the device to play on
- **Returns**: Success status
- **Example**: `playMusic({ uri: "spotify:track:6rqhFgbbKwnb9MLmUQDhG6" })`
- **Alternative**: `playMusic({ type: "track", id: "6rqhFgbbKwnb9MLmUQDhG6" })`
2. **pausePlayback**
- **Description**: Pause the currently playing track on Spotify
- **Parameters**:
- `deviceId` (string, optional): ID of the device to pause
- **Returns**: Success status
- **Example**: `pausePlayback()`
3. **skipToNext**
- **Description**: Skip to the next track in the current playback queue
- **Parameters**:
- `deviceId` (string, optional): ID of the device
- **Returns**: Success status
- **Example**: `skipToNext()`
4. **skipToPrevious**
- **Description**: Skip to the previous track in the current playback queue
- **Parameters**:
- `deviceId` (string, optional): ID of the device
- **Returns**: Success status
- **Example**: `skipToPrevious()`
5. **createPlaylist**
- **Description**: Create a new playlist on Spotify
- **Parameters**:
- `name` (string): Name for the new playlist
- `description` (string, optional): Description for the playlist
- `public` (boolean, optional): Whether the playlist should be public (default: false)
- **Returns**: Object with the new playlist's ID and URL
- **Example**: `createPlaylist({ name: "Workout Mix", description: "Songs to get pumped up", public: false })`
6. **addTracksToPlaylist**
- **Description**: Add tracks to an existing Spotify playlist
- **Parameters**:
- `playlistId` (string): ID of the playlist
- `trackUris` (array): Array of track URIs or IDs to add
- `position` (number, optional): Position to insert tracks
- **Returns**: Success status and snapshot ID
- **Example**: `addTracksToPlaylist({ playlistId: "3cEYpjA9oz9GiPac4AsH4n", trackUris: ["spotify:track:4iV5W9uYEdYUVa79Axb7Rh"] })`
7. **addToQueue**
- **Description**: Adds a track, album, artist or playlist to the current playback queue
- - **Parameters**:
- `uri` (string, optional): Spotify URI of the item to add to queue (overrides type and id)
- `type` (string, optional): Type of item to queue (track, album, artist, playlist)
- `id` (string, optional): Spotify ID of the item to queue
- `deviceId` (string, optional): ID of the device to queue on
- **Returns**: Success status
- **Example**: `addToQueue({ uri: "spotify:track:6rqhFgbbKwnb9MLmUQDhG6" })`
- **Alternative**: `addToQueue({ type: "track", id: "6rqhFgbbKwnb9MLmUQDhG6" })`
## Setup
### Prerequisites
- Node.js v16+
- A Spotify Premium account
- A registered Spotify Developer application
### Installation
```bash
git clone https://github.com/marcelmarais/spotify-mcp-server.git
cd spotify-mcp-server
npm install
npm run build
```
### Creating a Spotify Developer Application
1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/)
2. Log in with your Spotify account
3. Click the "Create an App" button
4. Fill in the app name and description
5. Accept the Terms of Service and click "Create"
6. In your new app's dashboard, you'll see your **Client ID**
7. Click "Show Client Secret" to reveal your **Client Secret**
8. Click "Edit Settings" and add a Redirect URI (e.g., `http://localhost:8888/callback`)
9. Save your changes
### Spotify API Configuration
Create a `spotify-config.json` file in the project root (you can copy and modify the provided example):
```bash
# Copy the example config file
cp spotify-config.example.json spotify-config.json
```
Then edit the file with your credentials:
```json
{
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
"redirectUri": "http://localhost:8888/callback"
}
```
### Authentication Process
The Spotify API uses OAuth 2.0 for authentication. Follow these steps to authenticate your application:
1. Run the authentication script:
```bash
npm run auth
```
2. The script will generate an authorization URL. Open this URL in your web browser.
3. You'll be prompted to log in to Spotify and authorize your application.
4. After authorization, Spotify will redirect you to your specified redirect URI with a code parameter in the URL.
5. The authentication script will automatically exchange this code for access and refresh tokens.
6. These tokens will be saved to your `spotify-config.json` file, which will now look something like:
```json
{
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
"redirectUri": "http://localhost:8888/callback",
"accessToken": "BQAi9Pn...kKQ",
"refreshToken": "AQDQcj...7w",
"expiresAt": 1677889354671
}
```
7. The server will automatically refresh the access token when needed, using the refresh token.
## Integrating with Claude Desktop, Cursor, and VsCode [Via Cline model extension](https://marketplace.visualstudio.com/items/?itemName=saoudrizwan.claude-dev)
To use your MCP server with Claude Desktop, add it to your Claude configuration:
```json
{
"mcpServers": {
"spotify": {
"command": "node",
"args": ["spotify-mcp-server/build/index.js"]
}
}
}
```
For Cursor, go to the MCP tab in `Cursor Settings` (command + shift + J). Add a server with this command:
```bash
node path/to/spotify-mcp-server/build/index.js
```
To set up your MCP correctly with Cline ensure you have the following file configuration set `cline_mcp_settings.json`:
```json
{
"mcpServers": {
"spotify": {
"command": "node",
"args": ["~/../spotify-mcp-server/build/index.js"],
"autoApprove": ["getListeningHistory", "getNowPlaying"]
}
}
}
```
You can add additional tools to the auto approval array to run the tools without intervention.

View file

@ -0,0 +1,134 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"files": {
"ignoreUnknown": false,
"ignore": [
"**/pnpm-lock.yaml",
"lib/db/migrations",
"lib/editor/react-renderer.tsx",
"node_modules",
".next",
"public",
".vercel"
]
},
"vcs": {
"enabled": true,
"clientKind": "git",
"defaultBranch": "main",
"useIgnoreFile": true
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 80,
"attributePosition": "auto"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"a11y": {
"useHtmlLang": "warn", // Not in recommended ruleset, turning on manually
"noHeaderScope": "warn", // Not in recommended ruleset, turning on manually
"useValidAriaRole": {
"level": "warn",
"options": {
"ignoreNonDom": false,
"allowInvalidRoles": ["none", "text"]
}
},
"useSemanticElements": "off", // Rule is buggy, revisit later
"noSvgWithoutTitle": "off", // We do not intend to adhere to this rule
"useMediaCaption": "off", // We would need a cultural change to turn this on
"noAutofocus": "off", // We're highly intentional about when we use autofocus
"noBlankTarget": "off", // Covered by Conformance
"useFocusableInteractive": "off", // Disable focusable interactive element requirement
"useAriaPropsForRole": "off", // Disable required ARIA attributes check
"useKeyWithClickEvents": "off" // Disable keyboard event requirement with click events
},
"complexity": {
"noUselessStringConcat": "warn", // Not in recommended ruleset, turning on manually
"noForEach": "off", // forEach is too familiar to ban
"noUselessSwitchCase": "off", // Turned off due to developer preferences
"noUselessThisAlias": "off" // Turned off due to developer preferences
},
"correctness": {
"noUnusedImports": "warn", // Not in recommended ruleset, turning on manually
"useArrayLiterals": "warn", // Not in recommended ruleset, turning on manually
"noNewSymbol": "warn", // Not in recommended ruleset, turning on manually
"useJsxKeyInIterable": "off", // Rule is buggy, revisit later
"useExhaustiveDependencies": "off", // Community feedback on this rule has been poor, we will continue with ESLint
"noUnnecessaryContinue": "off" // Turned off due to developer preferences
},
"security": {
"noDangerouslySetInnerHtml": "off" // Covered by Conformance
},
"style": {
"useFragmentSyntax": "warn", // Not in recommended ruleset, turning on manually
"noYodaExpression": "warn", // Not in recommended ruleset, turning on manually
"useDefaultParameterLast": "warn", // Not in recommended ruleset, turning on manually
"useExponentiationOperator": "off", // Obscure and arguably not easily readable
"noUnusedTemplateLiteral": "off", // Stylistic opinion
"noUselessElse": "off" // Stylistic opinion
},
"suspicious": {
"noExplicitAny": "off" // We trust Vercelians to use any only when necessary
},
"nursery": {
"noStaticElementInteractions": "warn",
"noHeadImportInDocument": "warn",
"noDocumentImportInPage": "warn",
"noDuplicateElseIf": "warn",
"noIrregularWhitespace": "warn",
"useValidAutocomplete": "warn"
}
}
},
"javascript": {
"jsxRuntime": "reactClassic",
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "all",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSpacing": true,
"bracketSameLine": false,
"quoteStyle": "single",
"attributePosition": "auto"
}
},
"json": {
"formatter": {
"enabled": true,
"trailingCommas": "none"
},
"parser": {
"allowComments": true,
"allowTrailingCommas": false
}
},
"css": {
"formatter": { "enabled": false },
"linter": { "enabled": false }
},
"organizeImports": { "enabled": false },
"overrides": [
// Playwright requires an object destructure, even if empty
// https://github.com/microsoft/playwright/issues/30007
{
"include": ["playwright/**"],
"linter": {
"rules": {
"correctness": {
"noEmptyPattern": "off"
}
}
}
}
]
}

View file

@ -0,0 +1,14 @@
#!/usr/bin/env node
import { authorizeSpotify } from './utils.js';
console.log('Starting Spotify authentication flow...');
authorizeSpotify()
.then(() => {
console.log('Authentication completed successfully!');
process.exit(0);
})
.catch((error) => {
console.error('Authentication failed:', error);
process.exit(1);
});

View file

@ -0,0 +1,23 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { playTools } from './play.js';
import { readTools } from './read.js';
const server = new McpServer({
name: 'spotify-controller',
version: '1.0.0',
});
[...readTools, ...playTools].forEach((tool) => {
server.tool(tool.name, tool.description, tool.schema, tool.handler);
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((error) => {
console.error('Fatal error in main():', error);
process.exit(1);
});

View file

@ -0,0 +1,371 @@
import { handleSpotifyRequest } from './utils.js';
import { z } from 'zod';
import type { SpotifyHandlerExtra, tool } from './types.js';
const playMusic: tool<{
uri: z.ZodOptional<z.ZodString>;
type: z.ZodOptional<z.ZodEnum<['track', 'album', 'artist', 'playlist']>>;
id: z.ZodOptional<z.ZodString>;
deviceId: z.ZodOptional<z.ZodString>;
}> = {
name: 'playMusic',
description: 'Start playing a Spotify track, album, artist, or playlist',
schema: {
uri: z
.string()
.optional()
.describe('The Spotify URI to play (overrides type and id)'),
type: z
.enum(['track', 'album', 'artist', 'playlist'])
.optional()
.describe('The type of item to play'),
id: z.string().optional().describe('The Spotify ID of the item to play'),
deviceId: z
.string()
.optional()
.describe('The Spotify device ID to play on'),
},
handler: async (args, extra: SpotifyHandlerExtra) => {
const { uri, type, id, deviceId } = args;
if (!uri && (!type || !id)) {
return {
content: [
{
type: 'text',
text: 'Error: Must provide either a URI or both a type and ID',
isError: true,
},
],
};
}
let spotifyUri = uri;
if (!spotifyUri && type && id) {
spotifyUri = `spotify:${type}:${id}`;
}
await handleSpotifyRequest(async (spotifyApi) => {
const device = deviceId || '';
if (!spotifyUri) {
await spotifyApi.player.startResumePlayback(device);
return;
}
if (type === 'track') {
await spotifyApi.player.startResumePlayback(device, undefined, [
spotifyUri,
]);
} else {
await spotifyApi.player.startResumePlayback(device, spotifyUri);
}
});
return {
content: [
{
type: 'text',
text: `Started playing ${type || 'music'} ${id ? `(ID: ${id})` : ''}`,
},
],
};
},
};
const pausePlayback: tool<{
deviceId: z.ZodOptional<z.ZodString>;
}> = {
name: 'pausePlayback',
description: 'Pause Spotify playback on the active device',
schema: {
deviceId: z
.string()
.optional()
.describe('The Spotify device ID to pause playback on'),
},
handler: async (args, extra: SpotifyHandlerExtra) => {
const { deviceId } = args;
await handleSpotifyRequest(async (spotifyApi) => {
await spotifyApi.player.pausePlayback(deviceId || '');
});
return {
content: [
{
type: 'text',
text: 'Playback paused',
},
],
};
},
};
const skipToNext: tool<{
deviceId: z.ZodOptional<z.ZodString>;
}> = {
name: 'skipToNext',
description: 'Skip to the next track in the current Spotify playback queue',
schema: {
deviceId: z
.string()
.optional()
.describe('The Spotify device ID to skip on'),
},
handler: async (args, extra: SpotifyHandlerExtra) => {
const { deviceId } = args;
await handleSpotifyRequest(async (spotifyApi) => {
await spotifyApi.player.skipToNext(deviceId || '');
});
return {
content: [
{
type: 'text',
text: 'Skipped to next track',
},
],
};
},
};
const skipToPrevious: tool<{
deviceId: z.ZodOptional<z.ZodString>;
}> = {
name: 'skipToPrevious',
description:
'Skip to the previous track in the current Spotify playback queue',
schema: {
deviceId: z
.string()
.optional()
.describe('The Spotify device ID to skip on'),
},
handler: async (args, extra: SpotifyHandlerExtra) => {
const { deviceId } = args;
await handleSpotifyRequest(async (spotifyApi) => {
await spotifyApi.player.skipToPrevious(deviceId || '');
});
return {
content: [
{
type: 'text',
text: 'Skipped to previous track',
},
],
};
},
};
const createPlaylist: tool<{
name: z.ZodString;
description: z.ZodOptional<z.ZodString>;
public: z.ZodOptional<z.ZodBoolean>;
}> = {
name: 'createPlaylist',
description: 'Create a new playlist on Spotify',
schema: {
name: z.string().describe('The name of the playlist'),
description: z
.string()
.optional()
.describe('The description of the playlist'),
public: z
.boolean()
.optional()
.describe('Whether the playlist should be public'),
},
handler: async (args, extra: SpotifyHandlerExtra) => {
const { name, description, public: isPublic = false } = args;
const result = await handleSpotifyRequest(async (spotifyApi) => {
const me = await spotifyApi.currentUser.profile();
return await spotifyApi.playlists.createPlaylist(me.id, {
name,
description,
public: isPublic,
});
});
return {
content: [
{
type: 'text',
text: `Successfully created playlist "${name}"\nPlaylist ID: ${result.id}`,
},
],
};
},
};
const addTracksToPlaylist: tool<{
playlistId: z.ZodString;
trackIds: z.ZodArray<z.ZodString>;
position: z.ZodOptional<z.ZodNumber>;
}> = {
name: 'addTracksToPlaylist',
description: 'Add tracks to a Spotify playlist',
schema: {
playlistId: z.string().describe('The Spotify ID of the playlist'),
trackIds: z.array(z.string()).describe('Array of Spotify track IDs to add'),
position: z
.number()
.nonnegative()
.optional()
.describe('Position to insert the tracks (0-based index)'),
},
handler: async (args, extra: SpotifyHandlerExtra) => {
const { playlistId, trackIds, position } = args;
if (trackIds.length === 0) {
return {
content: [
{
type: 'text',
text: 'Error: No track IDs provided',
},
],
};
}
try {
const trackUris = trackIds.map((id) => `spotify:track:${id}`);
await handleSpotifyRequest(async (spotifyApi) => {
await spotifyApi.playlists.addItemsToPlaylist(
playlistId,
trackUris,
position,
);
});
return {
content: [
{
type: 'text',
text: `Successfully added ${trackIds.length} track${
trackIds.length === 1 ? '' : 's'
} to playlist (ID: ${playlistId})`,
},
],
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `Error adding tracks to playlist: ${
error instanceof Error ? error.message : String(error)
}`,
},
],
};
}
},
};
const resumePlayback: tool<{
deviceId: z.ZodOptional<z.ZodString>;
}> = {
name: 'resumePlayback',
description: 'Resume Spotify playback on the active device',
schema: {
deviceId: z
.string()
.optional()
.describe('The Spotify device ID to resume playback on'),
},
handler: async (args, extra: SpotifyHandlerExtra) => {
const { deviceId } = args;
await handleSpotifyRequest(async (spotifyApi) => {
await spotifyApi.player.startResumePlayback(deviceId || '');
});
return {
content: [
{
type: 'text',
text: 'Playback resumed',
},
],
};
},
};
const addToQueue: tool<{
uri: z.ZodOptional<z.ZodString>;
type: z.ZodOptional<z.ZodEnum<['track', 'album', 'artist', 'playlist']>>;
id: z.ZodOptional<z.ZodString>;
deviceId: z.ZodOptional<z.ZodString>;
}> = {
name: 'addToQueue',
description: 'Adds a track, album, artist or playlist to the playback queue',
schema: {
uri: z
.string()
.optional()
.describe('The Spotify URI to play (overrides type and id)'),
type: z
.enum(['track', 'album', 'artist', 'playlist'])
.optional()
.describe('The type of item to play'),
id: z.string().optional().describe('The Spotify ID of the item to play'),
deviceId: z
.string()
.optional()
.describe('The Spotify device ID to add the track to'),
},
handler: async (args) => {
const { uri, type, id, deviceId } = args;
let spotifyUri = uri;
if (!spotifyUri && type && id) {
spotifyUri = `spotify:${type}:${id}`;
}
if (!spotifyUri) {
return {
content: [
{
type: 'text',
text: 'Error: Must provide either a URI or both a type and ID',
isError: true,
},
],
};
}
await handleSpotifyRequest(async (spotifyApi) => {
await spotifyApi.player.addItemToPlaybackQueue(
spotifyUri,
deviceId || '',
);
});
return {
content: [
{
type: 'text',
text: `Added item ${spotifyUri} to queue`,
},
],
};
},
};
export const playTools = [
playMusic,
pausePlayback,
skipToNext,
skipToPrevious,
createPlaylist,
addTracksToPlaylist,
resumePlayback,
addToQueue,
];

View file

@ -0,0 +1,366 @@
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
import type { MaxInt } from '@spotify/web-api-ts-sdk';
import { z } from 'zod';
import type { SpotifyHandlerExtra, SpotifyTrack, tool } from './types.js';
import { formatDuration, handleSpotifyRequest } from './utils.js';
function isTrack(item: any): item is SpotifyTrack {
return (
item &&
item.type === 'track' &&
Array.isArray(item.artists) &&
item.album &&
typeof item.album.name === 'string'
);
}
const searchSpotify: tool<{
query: z.ZodString;
type: z.ZodEnum<['track', 'album', 'artist', 'playlist']>;
limit: z.ZodOptional<z.ZodNumber>;
}> = {
name: 'searchSpotify',
description: 'Search for tracks, albums, artists, or playlists on Spotify',
schema: {
query: z.string().describe('The search query'),
type: z
.enum(['track', 'album', 'artist', 'playlist'])
.describe(
'The type of item to search for either track, album, artist, or playlist',
),
limit: z
.number()
.min(1)
.max(50)
.optional()
.describe('Maximum number of results to return (10-50)'),
},
handler: async (args, extra: SpotifyHandlerExtra) => {
const { query, type, limit } = args;
const limitValue = limit ?? 10;
try {
const results = await handleSpotifyRequest(async (spotifyApi) => {
return await spotifyApi.search(
query,
[type],
undefined,
limitValue as MaxInt<50>,
);
});
let formattedResults = '';
if (type === 'track' && results.tracks) {
formattedResults = results.tracks.items
.map((track, i) => {
const artists = track.artists.map((a) => a.name).join(', ');
const duration = formatDuration(track.duration_ms);
return `${i + 1}. "${
track.name
}" by ${artists} (${duration}) - ID: ${track.id}`;
})
.join('\n');
} else if (type === 'album' && results.albums) {
formattedResults = results.albums.items
.map((album, i) => {
const artists = album.artists.map((a) => a.name).join(', ');
return `${i + 1}. "${album.name}" by ${artists} - ID: ${album.id}`;
})
.join('\n');
} else if (type === 'artist' && results.artists) {
formattedResults = results.artists.items
.map((artist, i) => {
return `${i + 1}. ${artist.name} - ID: ${artist.id}`;
})
.join('\n');
} else if (type === 'playlist' && results.playlists) {
formattedResults = results.playlists.items
.map((playlist, i) => {
return `${i + 1}. "${playlist?.name ?? 'Unknown Playlist'} (${
playlist?.description ?? 'No description'
} tracks)" by ${playlist?.owner?.display_name} - ID: ${
playlist?.id
}`;
})
.join('\n');
}
return {
content: [
{
type: 'text',
text:
formattedResults.length > 0
? `# Search results for "${query}" (type: ${type})\n\n${formattedResults}`
: `No ${type} results found for "${query}"`,
},
],
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `Error searching for ${type}s: ${
error instanceof Error ? error.message : String(error)
}`,
},
],
};
}
},
};
const getNowPlaying: tool<Record<string, never>> = {
name: 'getNowPlaying',
description: 'Get information about the currently playing track on Spotify',
schema: {},
handler: async (args, extra: SpotifyHandlerExtra) => {
try {
const currentTrack = await handleSpotifyRequest(async (spotifyApi) => {
return await spotifyApi.player.getCurrentlyPlayingTrack();
});
if (!currentTrack || !currentTrack.item) {
return {
content: [
{
type: 'text',
text: 'Nothing is currently playing on Spotify',
},
],
};
}
const item = currentTrack.item;
if (!isTrack(item)) {
return {
content: [
{
type: 'text',
text: 'Currently playing item is not a track (might be a podcast episode)',
},
],
};
}
const artists = item.artists.map((a) => a.name).join(', ');
const album = item.album.name;
const duration = formatDuration(item.duration_ms);
const progress = formatDuration(currentTrack.progress_ms || 0);
const isPlaying = currentTrack.is_playing;
return {
content: [
{
type: 'text',
text:
`# Currently ${isPlaying ? 'Playing' : 'Paused'}\n\n` +
`**Track**: "${item.name}"\n` +
`**Artist**: ${artists}\n` +
`**Album**: ${album}\n` +
`**Progress**: ${progress} / ${duration}\n` +
`**ID**: ${item.id}`,
},
],
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `Error getting current track: ${
error instanceof Error ? error.message : String(error)
}`,
},
],
};
}
},
};
const getMyPlaylists: tool<{
limit: z.ZodOptional<z.ZodNumber>;
}> = {
name: 'getMyPlaylists',
description: "Get a list of the current user's playlists on Spotify",
schema: {
limit: z
.number()
.min(1)
.max(50)
.optional()
.describe('Maximum number of playlists to return (1-50)'),
},
handler: async (args, extra: SpotifyHandlerExtra) => {
const { limit = 50 } = args;
const playlists = await handleSpotifyRequest(async (spotifyApi) => {
return await spotifyApi.currentUser.playlists.playlists(
limit as MaxInt<50>,
);
});
if (playlists.items.length === 0) {
return {
content: [
{
type: 'text',
text: "You don't have any playlists on Spotify",
},
],
};
}
const formattedPlaylists = playlists.items
.map((playlist, i) => {
const tracksTotal = playlist.tracks?.total ? playlist.tracks.total : 0;
return `${i + 1}. "${playlist.name}" (${tracksTotal} tracks) - ID: ${
playlist.id
}`;
})
.join('\n');
return {
content: [
{
type: 'text',
text: `# Your Spotify Playlists\n\n${formattedPlaylists}`,
},
],
};
},
};
const getPlaylistTracks: tool<{
playlistId: z.ZodString;
limit: z.ZodOptional<z.ZodNumber>;
}> = {
name: 'getPlaylistTracks',
description: 'Get a list of tracks in a Spotify playlist',
schema: {
playlistId: z.string().describe('The Spotify ID of the playlist'),
limit: z
.number()
.min(1)
.max(50)
.optional()
.describe('Maximum number of tracks to return (1-50)'),
},
handler: async (args, extra: SpotifyHandlerExtra) => {
const { playlistId, limit = 50 } = args;
const playlistTracks = await handleSpotifyRequest(async (spotifyApi) => {
return await spotifyApi.playlists.getPlaylistItems(
playlistId,
undefined,
undefined,
limit as MaxInt<50>,
);
});
if ((playlistTracks.items?.length ?? 0) === 0) {
return {
content: [
{
type: 'text',
text: "This playlist doesn't have any tracks",
},
],
};
}
const formattedTracks = playlistTracks.items
.map((item, i) => {
const { track } = item;
if (!track) return `${i + 1}. [Removed track]`;
if (isTrack(track)) {
const artists = track.artists.map((a) => a.name).join(', ');
const duration = formatDuration(track.duration_ms);
return `${i + 1}. "${track.name}" by ${artists} (${duration}) - ID: ${track.id}`;
}
return `${i + 1}. Unknown item`;
})
.join('\n');
return {
content: [
{
type: 'text',
text: `# Tracks in Playlist\n\n${formattedTracks}`,
},
],
};
},
};
const getRecentlyPlayed: tool<{
limit: z.ZodOptional<z.ZodNumber>;
}> = {
name: 'getRecentlyPlayed',
description: 'Get a list of recently played tracks on Spotify',
schema: {
limit: z
.number()
.min(1)
.max(50)
.optional()
.describe('Maximum number of tracks to return (1-50)'),
},
handler: async (args, extra: SpotifyHandlerExtra) => {
const { limit = 50 } = args;
const history = await handleSpotifyRequest(async (spotifyApi) => {
return await spotifyApi.player.getRecentlyPlayedTracks(
limit as MaxInt<50>,
);
});
if (history.items.length === 0) {
return {
content: [
{
type: 'text',
text: "You don't have any recently played tracks on Spotify",
},
],
};
}
const formattedHistory = history.items
.map((item, i) => {
const track = item.track;
if (!track) return `${i + 1}. [Removed track]`;
if (isTrack(track)) {
const artists = track.artists.map((a) => a.name).join(', ');
const duration = formatDuration(track.duration_ms);
return `${i + 1}. "${track.name}" by ${artists} (${duration}) - ID: ${track.id}`;
}
return `${i + 1}. Unknown item`;
})
.join('\n');
return {
content: [
{
type: 'text',
text: `# Recently Played Tracks\n\n${formattedHistory}`,
},
],
};
}
}
export const readTools = [
searchSpotify,
getNowPlaying,
getMyPlaylists,
getPlaylistTracks,
getRecentlyPlayed,
];

View file

@ -0,0 +1,47 @@
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
import { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types.js';
import type { z } from 'zod';
export type SpotifyHandlerExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
export type tool<Args extends z.ZodRawShape> = {
name: string;
description: string;
schema: Args;
handler: (
args: z.infer<z.ZodObject<Args>>,
extra: SpotifyHandlerExtra,
) =>
| Promise<{
content: Array<{
type: 'text';
text: string;
}>;
}>
| {
content: Array<{
type: 'text';
text: string;
}>;
};
};
export interface SpotifyArtist {
id: string;
name: string;
}
export interface SpotifyAlbum {
id: string;
name: string;
artists: SpotifyArtist[];
}
export interface SpotifyTrack {
id: string;
name: string;
type: string;
duration_ms: number;
artists: SpotifyArtist[];
album: SpotifyAlbum;
}

View file

@ -0,0 +1,301 @@
import { SpotifyApi } from '@spotify/web-api-ts-sdk';
import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath, URL } from 'node:url';
import open from 'open';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_FILE = path.join(__dirname, '../spotify-config.json');
export interface SpotifyConfig {
clientId: string;
clientSecret: string;
redirectUri: string;
accessToken?: string;
refreshToken?: string;
}
export function loadSpotifyConfig(): SpotifyConfig {
if (!fs.existsSync(CONFIG_FILE)) {
throw new Error(
`Spotify configuration file not found at ${CONFIG_FILE}. Please create one with clientId, clientSecret, and redirectUri.`,
);
}
try {
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
if (!config.clientId || !config.clientSecret || !config.redirectUri) {
throw new Error(
'Spotify configuration must include clientId, clientSecret, and redirectUri.',
);
}
return config;
} catch (error) {
throw new Error(
`Failed to parse Spotify configuration: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
export function saveSpotifyConfig(config: SpotifyConfig): void {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
}
let cachedSpotifyApi: SpotifyApi | null = null;
export function createSpotifyApi(): SpotifyApi {
if (cachedSpotifyApi) {
return cachedSpotifyApi;
}
const config = loadSpotifyConfig();
if (config.accessToken && config.refreshToken) {
const accessToken = {
access_token: config.accessToken,
token_type: 'Bearer',
expires_in: 3600 * 24 * 30, // Default to 1 month
refresh_token: config.refreshToken,
};
cachedSpotifyApi = SpotifyApi.withAccessToken(config.clientId, accessToken);
return cachedSpotifyApi;
}
cachedSpotifyApi = SpotifyApi.withClientCredentials(
config.clientId,
config.clientSecret,
);
return cachedSpotifyApi;
}
function generateRandomString(length: number): string {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return Array.from(array)
.map((b) =>
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'.charAt(
b % 62,
),
)
.join('');
}
function base64Encode(str: string): string {
return Buffer.from(str).toString('base64');
}
async function exchangeCodeForToken(
code: string,
config: SpotifyConfig,
): Promise<{ access_token: string; refresh_token: string }> {
const tokenUrl = 'https://accounts.spotify.com/api/token';
const authHeader = `Basic ${base64Encode(`${config.clientId}:${config.clientSecret}`)}`;
const params = new URLSearchParams();
params.append('grant_type', 'authorization_code');
params.append('code', code);
params.append('redirect_uri', config.redirectUri);
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
Authorization: authHeader,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(`Failed to exchange code for token: ${errorData}`);
}
const data = await response.json();
return {
access_token: data.access_token,
refresh_token: data.refresh_token,
};
}
export async function authorizeSpotify(): Promise<void> {
const config = loadSpotifyConfig();
const redirectUri = new URL(config.redirectUri);
if (
redirectUri.hostname !== 'localhost' &&
redirectUri.hostname !== '127.0.0.1'
) {
console.error(
'Error: Redirect URI must use localhost for automatic token exchange',
);
console.error(
'Please update your spotify-config.json with a localhost redirect URI',
);
console.error('Example: http://127.0.0.1:8888/callback');
process.exit(1);
}
const port = redirectUri.port || '80';
const callbackPath = redirectUri.pathname || '/callback';
const state = generateRandomString(16);
const scopes = [
'user-read-private',
'user-read-email',
'user-read-playback-state',
'user-modify-playback-state',
'user-read-currently-playing',
'playlist-read-private',
'playlist-modify-private',
'playlist-modify-public',
'user-library-read',
'user-library-modify',
'user-read-recently-played',
'user-modify-playback-state',
'user-read-playback-state',
'user-read-currently-playing'
];
const authParams = new URLSearchParams({
client_id: config.clientId,
response_type: 'code',
redirect_uri: config.redirectUri,
scope: scopes.join(' '),
state: state,
show_dialog: 'true',
});
const authorizationUrl = `https://accounts.spotify.com/authorize?${authParams.toString()}`;
const authPromise = new Promise<void>((resolve, reject) => {
// Create HTTP server to handle the callback
const server = http.createServer(async (req, res) => {
if (!req.url) {
return res.end('No URL provided');
}
const reqUrl = new URL(req.url, `http://localhost:${port}`);
if (reqUrl.pathname === callbackPath) {
const code = reqUrl.searchParams.get('code');
const returnedState = reqUrl.searchParams.get('state');
const error = reqUrl.searchParams.get('error');
res.writeHead(200, { 'Content-Type': 'text/html' });
if (error) {
console.error(`Authorization error: ${error}`);
res.end(
'<html><body><h1>Authentication Failed</h1><p>Please close this window and try again.</p></body></html>',
);
server.close();
reject(new Error(`Authorization failed: ${error}`));
return;
}
if (returnedState !== state) {
console.error('State mismatch error');
res.end(
'<html><body><h1>Authentication Failed</h1><p>State verification failed. Please close this window and try again.</p></body></html>',
);
server.close();
reject(new Error('State mismatch'));
return;
}
if (!code) {
console.error('No authorization code received');
res.end(
'<html><body><h1>Authentication Failed</h1><p>No authorization code received. Please close this window and try again.</p></body></html>',
);
server.close();
reject(new Error('No authorization code received'));
return;
}
try {
const tokens = await exchangeCodeForToken(code, config);
config.accessToken = tokens.access_token;
config.refreshToken = tokens.refresh_token;
saveSpotifyConfig(config);
res.end(
'<html><body><h1>Authentication Successful!</h1><p>You can now close this window and return to the application.</p></body></html>',
);
console.log(
'Authentication successful! Access token has been saved.',
);
server.close();
resolve();
} catch (error) {
console.error('Token exchange error:', error);
res.end(
'<html><body><h1>Authentication Failed</h1><p>Failed to exchange authorization code for tokens. Please close this window and try again.</p></body></html>',
);
server.close();
reject(error);
}
} else {
res.writeHead(404);
res.end();
}
});
server.listen(Number.parseInt(port), '127.0.0.1', () => {
console.log(
`Listening for Spotify authentication callback on port ${port}`,
);
console.log('Opening browser for authorization...');
open(authorizationUrl).catch((error: Error) => {
console.log(
'Failed to open browser automatically. Please visit this URL to authorize:',
);
console.log(authorizationUrl);
});
});
server.on('error', (error) => {
console.error(`Server error: ${error.message}`);
reject(error);
});
});
await authPromise;
}
export function formatDuration(ms: number): string {
const minutes = Math.floor(ms / 60000);
const seconds = ((ms % 60000) / 1000).toFixed(0);
return `${minutes}:${seconds.padStart(2, '0')}`;
}
export async function handleSpotifyRequest<T>(
action: (spotifyApi: SpotifyApi) => Promise<T>,
): Promise<T> {
try {
const spotifyApi = createSpotifyApi();
return await action(spotifyApi);
} catch (error) {
// Skip JSON parsing errors as these are actually successful operations
const errorMessage = error instanceof Error ? error.message : String(error);
if (
errorMessage.includes('Unexpected token') ||
errorMessage.includes('Unexpected non-whitespace character') ||
errorMessage.includes('Exponent part is missing a number in JSON')
) {
return undefined as T;
}
// Rethrow other errors
throw error;
}
}