Skip to main content
Glama
gabguerin

Hooktheory MCP Server

by gabguerin

get_songs_by_progression

Find songs that use a specific chord progression from the Hooktheory database. Input chord IDs to retrieve matching songs with artist, section, and URL details.

Instructions

Get songs that contain a specific chord progression from Hooktheory. Args: cp: Chord progression using comma-separated chord IDs (e.g., "1,5,6,4" for I-V-vi-IV, "4,1" for IV-I) page: Page number for pagination (default: 1, each page contains ~20 results) key: Musical key filter (e.g., "C", "Am") mode: Scale mode filter (e.g., "major", "minor") Returns: JSON string containing array of songs with artist, song, section, and URL

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
cpYes
pageNo
keyNo
modeNo

Implementation Reference

  • Main implementation of get_songs_by_progression tool. This async function is decorated with @mcp.tool() and accepts chord progression parameters (cp, page, key, mode), calls the Hooktheory API via the client, and returns the result as a JSON string.
    @mcp.tool() async def get_songs_by_progression( cp: str, page: int = 1, key: Optional[str] = None, mode: Optional[str] = None, ) -> str: """ Get songs that contain a specific chord progression from Hooktheory. Args: cp: Chord progression using comma-separated chord IDs (e.g., "1,5,6,4" for I-V-vi-IV, "4,1" for IV-I) page: Page number for pagination (default: 1, each page contains ~20 results) key: Musical key filter (e.g., "C", "Am") mode: Scale mode filter (e.g., "major", "minor") Returns: JSON string containing array of songs with artist, song, section, and URL """ try: params: Dict[str, Any] = {"cp": cp} if page > 1: params["page"] = page if key: params["key"] = key if mode: params["mode"] = mode result = await hooktheory_client._make_request("songs", params) return str(result) except Exception as e: error_msg = f"Error fetching songs with progression {cp}: {str(e)}" logger.error(error_msg) return error_msg
  • Function signature and docstring defining the input/output schema for get_songs_by_progression, including type hints for parameters (cp: str, page: int, key: Optional[str], mode: Optional[str]) and return type (str).
    @mcp.tool() async def get_songs_by_progression( cp: str, page: int = 1, key: Optional[str] = None, mode: Optional[str] = None, ) -> str: """ Get songs that contain a specific chord progression from Hooktheory. Args: cp: Chord progression using comma-separated chord IDs (e.g., "1,5,6,4" for I-V-vi-IV, "4,1" for IV-I) page: Page number for pagination (default: 1, each page contains ~20 results) key: Musical key filter (e.g., "C", "Am") mode: Scale mode filter (e.g., "major", "minor") Returns: JSON string containing array of songs with artist, song, section, and URL """
  • HooktheoryClient helper class that provides the underlying API interaction functionality used by get_songs_by_progression. Includes OAuth authentication, rate limiting with exponential backoff, and the _make_request method that handles HTTP requests to the Hooktheory API.
    class HooktheoryClient: """Client for interacting with the Hooktheory API with OAuth 2 authentication.""" def __init__(self, base_url: str = "https://api.hooktheory.com/v1"): self.base_url = base_url self.trends_base_url = f"{base_url}/trends" self.username = os.getenv("HOOKTHEORY_USERNAME") self.password = os.getenv("HOOKTHEORY_PASSWORD") # Token management self.access_token: Optional[str] = None self.user_id: Optional[int] = None self.token_expires_at: Optional[float] = None # Rate limiting self.rate_limiter = RateLimiter( max_requests_per_second=1.5 ) # Conservative rate async def _authenticate(self) -> Dict[str, Any]: """Authenticate with Hooktheory API using username/password.""" if not self.username or not self.password: raise ValueError( "HOOKTHEORY_USERNAME and HOOKTHEORY_PASSWORD environment variables are required" ) auth_url = f"{self.base_url}/users/auth" auth_data = {"username": self.username, "password": self.password} async with httpx.AsyncClient() as client: try: logger.info("Authenticating with Hooktheory API") response = await client.post(auth_url, data=auth_data) response.raise_for_status() auth_response = response.json() logger.info( f"Authentication successful for user: {auth_response.get('username')}" ) return auth_response except httpx.HTTPStatusError as e: logger.error(f"Authentication failed: HTTP {e.response.status_code}") if e.response.status_code == 401: raise ValueError("Invalid username or password") raise except httpx.RequestError as e: logger.error(f"Request error during authentication: {e}") raise except Exception as e: logger.error(f"Unexpected error during authentication: {e}") raise async def _ensure_authenticated(self): """Ensure we have a valid access token.""" # Check if we already have a valid token if ( self.access_token and self.token_expires_at and time.time() < self.token_expires_at ): return # Authenticate and get new token auth_response = await self._authenticate() self.access_token = auth_response["activkey"] self.user_id = auth_response.get("id") # Set expiration time (assume 24 hours if not specified) self.token_expires_at = time.time() + (24 * 60 * 60) logger.info("Access token updated successfully") async def _make_request( self, endpoint: str, params: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """Make an authenticated request to the Hooktheory API with rate limiting.""" # Ensure we have a valid token await self._ensure_authenticated() # Apply rate limiting await self.rate_limiter.wait() headers = { "Authorization": f"Bearer {self.access_token}", "User-Agent": "Hooktheory-MCP-Server/0.2.2", "Content-Type": "application/json", } url = f"{self.trends_base_url}/{endpoint}" async with httpx.AsyncClient(timeout=30.0) as client: try: logger.debug(f"Making request to {url} with params: {params}") response = await client.get(url, headers=headers, params=params or {}) if response.status_code == 429: # Rate limited - apply backoff self.rate_limiter.on_failure() retry_after = int(response.headers.get("Retry-After", "60")) logger.warning( f"Rate limited. Waiting {retry_after} seconds before retry" ) await asyncio.sleep(retry_after) # Retry once after rate limit await self.rate_limiter.wait() response = await client.get( url, headers=headers, params=params or {} ) if response.status_code == 401: # Token might be expired, force re-authentication logger.info("Received 401, forcing re-authentication") self.access_token = None self.token_expires_at = None await self._ensure_authenticated() # Update headers with new token headers["Authorization"] = f"Bearer {self.access_token}" # Retry with new token await self.rate_limiter.wait() response = await client.get( url, headers=headers, params=params or {} ) response.raise_for_status() self.rate_limiter.on_success() result = response.json() logger.debug( f"Request successful: {len(str(result))} characters returned" ) return result except httpx.HTTPStatusError as e: self.rate_limiter.on_failure() logger.error(f"HTTP {e.response.status_code} error calling {url}: {e}") raise except httpx.RequestError as e: self.rate_limiter.on_failure() logger.error(f"Request error calling {url}: {e}") raise except Exception as e: self.rate_limiter.on_failure() logger.error(f"Unexpected error calling {url}: {e}") raise
  • RateLimiter helper class used by HooktheoryClient to implement rate limiting with exponential backoff. Ensures API requests stay within acceptable limits and handles failures with increasing delay.
    class RateLimiter: """Simple rate limiter with exponential backoff.""" def __init__(self, max_requests_per_second: float = 2.0): self.max_requests_per_second = max_requests_per_second self.min_interval = 1.0 / max_requests_per_second self.last_request_time = 0.0 self.backoff_delay = 0.0 self.consecutive_failures = 0 async def wait(self): """Wait if necessary to respect rate limits.""" current_time = time.time() time_since_last = current_time - self.last_request_time # Apply backoff delay if we have consecutive failures total_delay = max(self.min_interval - time_since_last, self.backoff_delay) if total_delay > 0: await asyncio.sleep(total_delay) self.last_request_time = time.time() def on_success(self): """Reset backoff on successful request.""" self.consecutive_failures = 0 self.backoff_delay = 0.0 def on_failure(self): """Increase backoff delay on failed request.""" self.consecutive_failures += 1 # Exponential backoff: 1s, 2s, 4s, 8s, max 60s self.backoff_delay = min(60.0, 2 ** (self.consecutive_failures - 1))
  • Module initialization and FastMCP server setup. The @mcp.tool() decorator on line 217 registers get_songs_by_progression as an available tool in the MCP server instance created on line 24.
    """ Hooktheory MCP Server A Model Context Protocol server that enables agents to query the Hooktheory API for chord progression data and music theory statistics. Available tools: - get_songs_by_progression: Find songs that contain specific chord progressions - get_chord_transitions: Get chord statistics and transition probabilities """ import asyncio import logging import os import time from typing import Any, Dict, Optional import httpx from mcp.server.fastmcp import FastMCP logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) mcp = FastMCP("Hooktheory MCP Server")

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/gabguerin/hooktheory-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server