# MCP-Toolbox Development Guide for LLMs

This guide is designed to help you (an LLM) effectively contribute to the mcp-toolbox project. It provides essential information about the project structure, development workflow, and best practices.

## Project Overview

MCP-Toolbox is a Python package that provides tools for enhancing LLMs through the Model Context Protocol (MCP). The project implements various API integrations as MCP tools, allowing LLMs to interact with external services.

### Key Components

- **mcp_toolbox/app.py**: Initializes the FastMCP server
- **mcp_toolbox/cli.py**: Command-line interface for running the MCP server
- **mcp_toolbox/config.py**: Configuration management using Pydantic
- **mcp_toolbox/figma/**: Figma API integration tools
- **tests/**: Test files for the project

## Environment Setup

Always help the user set up a proper development environment using `uv`. This is the preferred package manager for this project.

### Setting Up the Environment

```bash
# Install uv if not already installed
curl -LsSf https://astral.sh/uv/install.sh | sh  # For macOS/Linux
# or
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"  # For Windows

# Clone the repository (if not already done)
git clone https://github.com/username/mcp-toolbox.git
cd mcp-toolbox

# Create and activate a virtual environment
uv venv
source .venv/bin/activate  # For macOS/Linux
# or
.venv\Scripts\activate  # For Windows

# Install the package in development mode
uv pip install -e .

# Install development dependencies
uv pip install -e ".[dev]"
```

## GitHub Workflow

Always follow proper GitHub workflow when making changes:

1. **Create a branch with a descriptive name**:
   ```bash
   # Assume the user already has their own fork
   git checkout -b feature/add-spotify-integration
   ```

2. **Make your changes**: Implement the requested features or fixes

3. **Run tests and checks**:
   ```bash
   make check  # Run linting and formatting
   make test   # Run tests
   ```

4. **Commit your changes with descriptive messages**:
   ```bash
   git add .
   git commit -m "feat: add Spotify API integration"
   ```

5. **Push your changes**:
   ```bash
   git push origin feature/add-spotify-integration
   ```

6. **Create a pull request**: Guide the user to create a PR from their branch to the main repository

## Adding New Tools

When adding new API integrations or tools, follow this pattern. Here's an example of adding Spotify API integration:

### 1. Update Config Class

First, update the `config.py` file to include the new API key:

```python
class Config(BaseSettings):
    figma_api_key: str | None = None
    spotify_client_id: str | None = None
    spotify_client_secret: str | None = None

    cache_dir: str = (HOME / "cache").expanduser().resolve().absolute().as_posix()
```

### 2. Create Module Structure

Create a new module for the integration:

```bash
mkdir -p mcp_toolbox/spotify
touch mcp_toolbox/spotify/__init__.py
touch mcp_toolbox/spotify/tools.py
```

### 3. Implement API Client and Tools

In `mcp_toolbox/spotify/tools.py`:

```python
import json
from typing import Any, List, Dict, Optional

import httpx
from pydantic import BaseModel

from mcp_toolbox.app import mcp
from mcp_toolbox.config import Config


class SpotifyApiClient:
    BASE_URL = "https://api.spotify.com/v1"

    def __init__(self):
        self.config = Config()
        self.access_token = None

    async def get_access_token(self) -> str:
        """Get or refresh the Spotify access token."""
        if not self.config.spotify_client_id or not self.config.spotify_client_secret:
            raise ValueError(
                "Spotify credentials not provided. Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables."
            )

        auth_url = "https://accounts.spotify.com/api/token"

        async with httpx.AsyncClient() as client:
            response = await client.post(
                auth_url,
                data={"grant_type": "client_credentials"},
                auth=(self.config.spotify_client_id, self.config.spotify_client_secret),
            )
            response.raise_for_status()
            data = response.json()
            self.access_token = data["access_token"]
            return self.access_token

    async def make_request(self, path: str, method: str = "GET", params: Dict[str, Any] = None) -> Dict[str, Any]:
        """Make a request to the Spotify API."""
        token = await self.get_access_token()

        async with httpx.AsyncClient() as client:
            headers = {"Authorization": f"Bearer {token}"}
            url = f"{self.BASE_URL}{path}"

            try:
                if method == "GET":
                    response = await client.get(url, headers=headers, params=params)
                else:
                    raise ValueError(f"Unsupported HTTP method: {method}")

                response.raise_for_status()
                return response.json()
            except httpx.HTTPStatusError as e:
                spotify_error = e.response.json() if e.response.content else {"status": e.response.status_code, "error": str(e)}
                raise ValueError(f"Spotify API error: {spotify_error}") from e
            except httpx.RequestError as e:
                raise ValueError(f"Request error: {e!s}") from e


# Initialize API client
api_client = SpotifyApiClient()


# Tool implementations
@mcp.tool(
    description="Search for tracks on Spotify. Args: query (required, The search query), limit (optional, Maximum number of results to return)"
)
async def spotify_search_tracks(query: str, limit: int = 10) -> Dict[str, Any]:
    """Search for tracks on Spotify.

    Args:
        query: The search query
        limit: Maximum number of results to return (default: 10)
    """
    params = {"q": query, "type": "track", "limit": limit}
    return await api_client.make_request("/search", params=params)


@mcp.tool(
    description="Get details about a specific track. Args: track_id (required, The Spotify ID of the track)"
)
async def spotify_get_track(track_id: str) -> Dict[str, Any]:
    """Get details about a specific track.

    Args:
        track_id: The Spotify ID of the track
    """
    return await api_client.make_request(f"/tracks/{track_id}")


@mcp.tool(
    description="Get an artist's top tracks. Args: artist_id (required, The Spotify ID of the artist), market (optional, An ISO 3166-1 alpha-2 country code)"
)
async def spotify_get_artist_top_tracks(artist_id: str, market: str = "US") -> Dict[str, Any]:
    """Get an artist's top tracks.

    Args:
        artist_id: The Spotify ID of the artist
        market: An ISO 3166-1 alpha-2 country code (default: US)
    """
    params = {"market": market}
    return await api_client.make_request(f"/artists/{artist_id}/top-tracks", params=params)
```

### 4. Create Tests

Create test files for your new tools:

```bash
mkdir -p tests/spotify
touch tests/spotify/test_tools.py
mkdir -p tests/mock/spotify
```

### 5. Update README

Always update the README.md when adding new environment variables or tools:

```markdown
## Environment Variables

The following environment variables can be configured:

- `FIGMA_API_KEY`: API key for Figma integration
- `SPOTIFY_CLIENT_ID`: Client ID for Spotify API
- `SPOTIFY_CLIENT_SECRET`: Client Secret for Spotify API
```

## Error Handling Best Practices

When implementing tools, follow these error handling best practices:

1. **Graceful Degradation**: If one API key is missing, other tools should still work
   ```python
   async def get_access_token(self) -> str:
       if not self.config.spotify_client_id or not self.config.spotify_client_secret:
           raise ValueError(
               "Spotify credentials not provided. Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables."
           )
       # Rest of the method...
   ```

2. **Descriptive Error Messages**: Provide clear error messages that help users understand what went wrong
   ```python
   except httpx.HTTPStatusError as e:
       spotify_error = e.response.json() if e.response.content else {"status": e.response.status_code, "error": str(e)}
       raise ValueError(f"Spotify API error: {spotify_error}") from e
   ```

3. **Proper Exception Handling**: Catch specific exceptions and handle them appropriately

4. **Fallbacks**: Implement fallback mechanisms when possible

## Testing

Always write tests for new functionality:

```python
import json
from pathlib import Path
from unittest.mock import patch

import pytest

from mcp_toolbox.spotify.tools import (
    SpotifyApiClient,
    spotify_search_tracks,
    spotify_get_track,
    spotify_get_artist_top_tracks,
)


# Helper function to load mock data
def load_mock_data(filename):
    mock_dir = Path(__file__).parent.parent / "mock" / "spotify"
    file_path = mock_dir / filename

    if not file_path.exists():
        # Create empty mock data if it doesn't exist
        mock_data = {"mock": "data"}
        with open(file_path, "w") as f:
            json.dump(mock_data, f)

    with open(file_path) as f:
        return json.load(f)


# Patch the SpotifyApiClient.make_request method
@pytest.fixture
def mock_make_request():
    with patch.object(SpotifyApiClient, "make_request") as mock:
        def side_effect(path, method="GET", params=None):
            if path == "/search":
                return load_mock_data("search_tracks.json")
            elif path.startswith("/tracks/"):
                return load_mock_data("get_track.json")
            elif path.endswith("/top-tracks"):
                return load_mock_data("get_artist_top_tracks.json")
            return {"mock": "data"}

        mock.side_effect = side_effect
        yield mock


# Test spotify_search_tracks function
@pytest.mark.asyncio
async def test_spotify_search_tracks(mock_make_request):
    result = await spotify_search_tracks("test query")
    mock_make_request.assert_called_once()
    assert mock_make_request.call_args[0][0] == "/search"


# Test spotify_get_track function
@pytest.mark.asyncio
async def test_spotify_get_track(mock_make_request):
    result = await spotify_get_track("track_id")
    mock_make_request.assert_called_once()
    assert mock_make_request.call_args[0][0] == "/tracks/track_id"


# Test spotify_get_artist_top_tracks function
@pytest.mark.asyncio
async def test_spotify_get_artist_top_tracks(mock_make_request):
    result = await spotify_get_artist_top_tracks("artist_id")
    mock_make_request.assert_called_once()
    assert mock_make_request.call_args[0][0] == "/artists/artist_id/top-tracks"
```

## Documentation

When adding new tools, make sure to:

1. Add clear docstrings to all functions and classes
2. Include detailed argument descriptions in the `@mcp.tool` decorator
3. Add type hints to all functions and methods
4. Update the README.md with new environment variables and tools

## Final Checklist

Before submitting your changes:

1. ✅ Run `make check` to ensure code quality
2. ✅ Run `make test` to ensure all tests pass
3. ✅ Update documentation if needed
4. ✅ Add new environment variables to README.md
5. ✅ Follow proper Git workflow (branch, commit, push)
