Skip to main content
Glama

LoreKeeper MCP

by frap129
plan.md54.9 kB
# Build API Clients Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use skills_executing_plans to implement this plan task-by-task. **Goal:** Build async HTTP client infrastructure and API clients for Open5e (v1, v2) and D&D 5e APIs to fetch game data with caching, retry logic, and error handling. **Architecture:** Base HTTP client with async/await, exponential backoff retry, SQLite cache integration. Separate clients for each API version with Pydantic models for response normalization. Factory pattern for dependency injection. **Tech Stack:** httpx (async HTTP), Pydantic (validation), aiosqlite (caching), respx (testing mocks), pytest-asyncio --- ## Task 1: Custom Exception Classes **Files:** - Create: `src/lorekeeper_mcp/api_clients/__init__.py` - Create: `src/lorekeeper_mcp/api_clients/exceptions.py` - Create: `tests/test_api_clients/__init__.py` - Create: `tests/test_api_clients/test_exceptions.py` **Step 1: Write the failing test** Create test file with exception hierarchy validation: ```python """Tests for API client custom exceptions.""" import pytest from lorekeeper_mcp.api_clients.exceptions import ( ApiClientError, ApiError, CacheError, NetworkError, ParseError, ) def test_exception_hierarchy() -> None: """Test that all exceptions inherit from ApiClientError.""" assert issubclass(NetworkError, ApiClientError) assert issubclass(ApiError, ApiClientError) assert issubclass(ParseError, ApiClientError) assert issubclass(CacheError, ApiClientError) def test_network_error_creation() -> None: """Test NetworkError can be created with message.""" error = NetworkError("Connection timeout") assert str(error) == "Connection timeout" assert isinstance(error, ApiClientError) def test_api_error_with_status_code() -> None: """Test ApiError stores status code.""" error = ApiError("Not found", status_code=404) assert str(error) == "Not found" assert error.status_code == 404 def test_parse_error_with_raw_data() -> None: """Test ParseError stores raw response data.""" raw_data = '{"invalid": json}' error = ParseError("Failed to parse JSON", raw_data=raw_data) assert str(error) == "Failed to parse JSON" assert error.raw_data == raw_data def test_cache_error_is_non_fatal() -> None: """Test CacheError represents non-fatal cache failures.""" error = CacheError("Cache write failed") assert str(error) == "Cache write failed" assert isinstance(error, ApiClientError) ``` **Step 2: Run test to verify it fails** Run: `pytest tests/test_api_clients/test_exceptions.py -v` Expected: FAIL with "ModuleNotFoundError: No module named 'lorekeeper_mcp.api_clients.exceptions'" **Step 3: Write minimal implementation** Create `src/lorekeeper_mcp/api_clients/exceptions.py`: ```python """Custom exceptions for API client operations.""" class ApiClientError(Exception): """Base exception for all API client errors.""" pass class NetworkError(ApiClientError): """Network-related errors (timeouts, connection failures) - retryable.""" pass class ApiError(ApiClientError): """API response errors (4xx/5xx status codes) - non-retryable.""" def __init__(self, message: str, status_code: int | None = None) -> None: """Initialize ApiError with optional status code. Args: message: Error message status_code: HTTP status code if available """ super().__init__(message) self.status_code = status_code class ParseError(ApiClientError): """Response parsing errors (malformed JSON, validation failures).""" def __init__(self, message: str, raw_data: str | None = None) -> None: """Initialize ParseError with optional raw response data. Args: message: Error message raw_data: Raw response data that failed to parse """ super().__init__(message) self.raw_data = raw_data class CacheError(ApiClientError): """Cache operation errors - non-fatal, should not break requests.""" pass ``` Create `src/lorekeeper_mcp/api_clients/__init__.py`: ```python """API client package for external D&D 5e data sources.""" from lorekeeper_mcp.api_clients.exceptions import ( ApiClientError, ApiError, CacheError, NetworkError, ParseError, ) __all__ = [ "ApiClientError", "NetworkError", "ApiError", "ParseError", "CacheError", ] ``` Create `tests/test_api_clients/__init__.py` (empty file): ```python """Tests for API clients.""" ``` **Step 4: Run test to verify it passes** Run: `pytest tests/test_api_clients/test_exceptions.py -v` Expected: PASS (5 tests) **Step 5: Commit** ```bash git add src/lorekeeper_mcp/api_clients/__init__.py git add src/lorekeeper_mcp/api_clients/exceptions.py git add tests/test_api_clients/__init__.py git add tests/test_api_clients/test_exceptions.py git commit -m "feat: add custom exception classes for API clients" ``` --- ## Task 2: Base HTTP Client - Request Method **Files:** - Create: `src/lorekeeper_mcp/api_clients/base.py` - Create: `tests/test_api_clients/test_base.py` **Step 1: Write the failing test** Create test file with basic request functionality: ```python """Tests for BaseHttpClient.""" import httpx import pytest import respx from lorekeeper_mcp.api_clients.base import BaseHttpClient from lorekeeper_mcp.api_clients.exceptions import ApiError, NetworkError @pytest.fixture def base_client() -> BaseHttpClient: """Create a BaseHttpClient instance for testing.""" return BaseHttpClient(base_url="https://api.example.com", timeout=5.0) @respx.mock async def test_make_request_success(base_client: BaseHttpClient) -> None: """Test successful HTTP GET request.""" mock_route = respx.get("https://api.example.com/test").mock( return_value=httpx.Response(200, json={"data": "success"}) ) response = await base_client._make_request("/test") assert response == {"data": "success"} assert mock_route.called @respx.mock async def test_make_request_404_error(base_client: BaseHttpClient) -> None: """Test API error handling for 404 response.""" respx.get("https://api.example.com/notfound").mock( return_value=httpx.Response(404, json={"error": "Not found"}) ) with pytest.raises(ApiError) as exc_info: await base_client._make_request("/notfound") assert exc_info.value.status_code == 404 @respx.mock async def test_make_request_timeout(base_client: BaseHttpClient) -> None: """Test network timeout handling.""" respx.get("https://api.example.com/slow").mock(side_effect=httpx.TimeoutException("Timeout")) with pytest.raises(NetworkError) as exc_info: await base_client._make_request("/slow", max_retries=1) assert "Timeout" in str(exc_info.value) ``` **Step 2: Run test to verify it fails** Run: `pytest tests/test_api_clients/test_base.py -v` Expected: FAIL with "ModuleNotFoundError: No module named 'lorekeeper_mcp.api_clients.base'" **Step 3: Write minimal implementation** Create `src/lorekeeper_mcp/api_clients/base.py`: ```python """Base HTTP client with retry logic and error handling.""" import asyncio import logging from typing import Any import httpx from lorekeeper_mcp.api_clients.exceptions import ApiError, NetworkError logger = logging.getLogger(__name__) class BaseHttpClient: """Base HTTP client providing common functionality for API requests.""" def __init__( self, base_url: str, timeout: float = 30.0, max_retries: int = 5, ) -> None: """Initialize the base HTTP client. Args: base_url: Base URL for API requests timeout: Request timeout in seconds max_retries: Maximum number of retry attempts """ self.base_url = base_url.rstrip("/") self.timeout = timeout self.max_retries = max_retries self._client: httpx.AsyncClient | None = None async def _get_client(self) -> httpx.AsyncClient: """Get or create async HTTP client.""" if self._client is None: self._client = httpx.AsyncClient( timeout=self.timeout, headers={"User-Agent": "LoreKeeper-MCP/0.1.0"}, ) return self._client async def _make_request( self, endpoint: str, method: str = "GET", max_retries: int | None = None, **kwargs: Any, ) -> dict[str, Any]: """Make HTTP request with retry logic. Args: endpoint: API endpoint path method: HTTP method max_retries: Override max_retries for this request **kwargs: Additional arguments for httpx request Returns: Parsed JSON response Raises: NetworkError: For network-related failures ApiError: For API error responses (4xx/5xx) """ url = f"{self.base_url}{endpoint}" retries = max_retries if max_retries is not None else self.max_retries client = await self._get_client() for attempt in range(retries + 1): try: response = await client.request(method, url, **kwargs) if response.status_code >= 400: raise ApiError( f"API error: {response.status_code}", status_code=response.status_code, ) return response.json() except httpx.TimeoutException as e: if attempt == retries: raise NetworkError(f"Request timeout after {retries + 1} attempts") from e await asyncio.sleep(2**attempt) # Exponential backoff except httpx.RequestError as e: if attempt == retries: raise NetworkError(f"Network error: {e}") from e await asyncio.sleep(2**attempt) # Should not reach here raise NetworkError("Max retries exceeded") async def close(self) -> None: """Close the HTTP client.""" if self._client: await self._client.aclose() self._client = None ``` Update `src/lorekeeper_mcp/api_clients/__init__.py`: ```python """API client package for external D&D 5e data sources.""" from lorekeeper_mcp.api_clients.base import BaseHttpClient from lorekeeper_mcp.api_clients.exceptions import ( ApiClientError, ApiError, CacheError, NetworkError, ParseError, ) __all__ = [ "BaseHttpClient", "ApiClientError", "NetworkError", "ApiError", "ParseError", "CacheError", ] ``` **Step 4: Run test to verify it passes** Run: `pytest tests/test_api_clients/test_base.py -v` Expected: PASS (3 tests) **Step 5: Commit** ```bash git add src/lorekeeper_mcp/api_clients/base.py git add src/lorekeeper_mcp/api_clients/__init__.py git add tests/test_api_clients/test_base.py git commit -m "feat: add base HTTP client with retry logic" ``` --- ## Task 3: Base HTTP Client - Cache Integration **Files:** - Modify: `src/lorekeeper_mcp/api_clients/base.py` - Modify: `tests/test_api_clients/test_base.py` **Step 1: Write the failing test** Add cache integration tests: ```python # Add to existing test_base.py imports from lorekeeper_mcp.cache.db import get_cached, set_cached @pytest.fixture async def base_client_with_cache(test_db: Any) -> BaseHttpClient: """Create BaseHttpClient with cache enabled for testing.""" client = BaseHttpClient(base_url="https://api.example.com", cache_ttl=3600) yield client await client.close() @respx.mock async def test_cache_hit(base_client_with_cache: BaseHttpClient) -> None: """Test that cached responses are returned without making HTTP request.""" # Pre-populate cache await set_cached("https://api.example.com/cached", {"data": "from_cache"}, ttl=3600) # Mock should not be called if cache works mock_route = respx.get("https://api.example.com/cached").mock( return_value=httpx.Response(200, json={"data": "from_api"}) ) response = await base_client_with_cache.make_request("/cached") assert response == {"data": "from_cache"} assert not mock_route.called @respx.mock async def test_cache_miss(base_client_with_cache: BaseHttpClient) -> None: """Test that cache miss results in HTTP request and cache update.""" mock_route = respx.get("https://api.example.com/uncached").mock( return_value=httpx.Response(200, json={"data": "from_api"}) ) response = await base_client_with_cache.make_request("/uncached") assert response == {"data": "from_api"} assert mock_route.called # Verify cache was updated cached = await get_cached("https://api.example.com/uncached") assert cached == {"data": "from_api"} @respx.mock async def test_cache_error_continues(base_client_with_cache: BaseHttpClient, monkeypatch: Any) -> None: """Test that cache errors don't break requests.""" # Mock cache to raise error async def failing_get_cached(*args: Any, **kwargs: Any) -> None: raise Exception("Cache read failed") monkeypatch.setattr("lorekeeper_mcp.api_clients.base.get_cached", failing_get_cached) mock_route = respx.get("https://api.example.com/test").mock( return_value=httpx.Response(200, json={"data": "success"}) ) # Should still succeed despite cache error response = await base_client_with_cache.make_request("/test") assert response == {"data": "success"} assert mock_route.called ``` **Step 2: Run test to verify it fails** Run: `pytest tests/test_api_clients/test_base.py::test_cache_hit -v` Expected: FAIL with "AttributeError: 'BaseHttpClient' object has no attribute 'make_request'" or "no cache_ttl parameter" **Step 3: Write minimal implementation** Update `src/lorekeeper_mcp/api_clients/base.py`: ```python """Base HTTP client with retry logic and error handling.""" import asyncio import logging from typing import Any import httpx from lorekeeper_mcp.api_clients.exceptions import ApiError, CacheError, NetworkError from lorekeeper_mcp.cache.db import get_cached, set_cached logger = logging.getLogger(__name__) class BaseHttpClient: """Base HTTP client providing common functionality for API requests.""" def __init__( self, base_url: str, timeout: float = 30.0, max_retries: int = 5, cache_ttl: int = 604800, # 7 days default ) -> None: """Initialize the base HTTP client. Args: base_url: Base URL for API requests timeout: Request timeout in seconds max_retries: Maximum number of retry attempts cache_ttl: Cache time-to-live in seconds (default 7 days) """ self.base_url = base_url.rstrip("/") self.timeout = timeout self.max_retries = max_retries self.cache_ttl = cache_ttl self._client: httpx.AsyncClient | None = None async def _get_client(self) -> httpx.AsyncClient: """Get or create async HTTP client.""" if self._client is None: self._client = httpx.AsyncClient( timeout=self.timeout, headers={"User-Agent": "LoreKeeper-MCP/0.1.0"}, ) return self._client async def _get_cached_response(self, cache_key: str) -> dict[str, Any] | None: """Get cached response if available. Args: cache_key: Cache key (typically full URL) Returns: Cached response or None if not found/expired """ try: return await get_cached(cache_key) except Exception as e: logger.warning(f"Cache read failed: {e}") return None async def _cache_response(self, cache_key: str, data: dict[str, Any]) -> None: """Cache response data. Args: cache_key: Cache key (typically full URL) data: Response data to cache """ try: await set_cached(cache_key, data, ttl=self.cache_ttl) except Exception as e: logger.warning(f"Cache write failed: {e}") # Non-fatal - continue without caching async def make_request( self, endpoint: str, method: str = "GET", use_cache: bool = True, **kwargs: Any, ) -> dict[str, Any]: """Make HTTP request with caching and retry logic. Args: endpoint: API endpoint path method: HTTP method use_cache: Whether to use cache for this request **kwargs: Additional arguments for httpx request Returns: Parsed JSON response Raises: NetworkError: For network-related failures ApiError: For API error responses (4xx/5xx) """ url = f"{self.base_url}{endpoint}" # Check cache first if use_cache and method == "GET": cached = await self._get_cached_response(url) if cached is not None: logger.debug(f"Cache hit: {url}") return cached # Make request (delegates to _make_request) response = await self._make_request(endpoint, method, **kwargs) # Cache successful response if use_cache and method == "GET": await self._cache_response(url, response) return response async def _make_request( self, endpoint: str, method: str = "GET", max_retries: int | None = None, **kwargs: Any, ) -> dict[str, Any]: """Make HTTP request with retry logic. Args: endpoint: API endpoint path method: HTTP method max_retries: Override max_retries for this request **kwargs: Additional arguments for httpx request Returns: Parsed JSON response Raises: NetworkError: For network-related failures ApiError: For API error responses (4xx/5xx) """ url = f"{self.base_url}{endpoint}" retries = max_retries if max_retries is not None else self.max_retries client = await self._get_client() for attempt in range(retries + 1): try: response = await client.request(method, url, **kwargs) if response.status_code >= 400: raise ApiError( f"API error: {response.status_code}", status_code=response.status_code, ) return response.json() except httpx.TimeoutException as e: if attempt == retries: raise NetworkError(f"Request timeout after {retries + 1} attempts") from e await asyncio.sleep(2**attempt) # Exponential backoff except httpx.RequestError as e: if attempt == retries: raise NetworkError(f"Network error: {e}") from e await asyncio.sleep(2**attempt) # Should not reach here raise NetworkError("Max retries exceeded") async def close(self) -> None: """Close the HTTP client.""" if self._client: await self._client.aclose() self._client = None ``` **Step 4: Run test to verify it passes** Run: `pytest tests/test_api_clients/test_base.py -v` Expected: PASS (all tests including new cache tests) **Step 5: Commit** ```bash git add src/lorekeeper_mcp/api_clients/base.py git add tests/test_api_clients/test_base.py git commit -m "feat: add cache integration to base HTTP client" ``` --- ## Task 4: Pydantic Base Models **Files:** - Create: `src/lorekeeper_mcp/api_clients/models/__init__.py` - Create: `src/lorekeeper_mcp/api_clients/models/base.py` - Create: `tests/test_api_clients/test_models.py` **Step 1: Write the failing test** ```python """Tests for Pydantic response models.""" import pytest from pydantic import ValidationError from lorekeeper_mcp.api_clients.models.base import BaseModel def test_base_model_required_fields() -> None: """Test that BaseModel validates required fields.""" model = BaseModel(name="Test Item", slug="test-item") assert model.name == "Test Item" assert model.slug == "test-item" def test_base_model_optional_fields() -> None: """Test that BaseModel handles optional fields.""" model = BaseModel( name="Test Item", slug="test-item", desc="Test description", document_url="https://example.com", ) assert model.desc == "Test description" assert model.document_url == "https://example.com" def test_base_model_missing_required_field() -> None: """Test that BaseModel raises error for missing required fields.""" with pytest.raises(ValidationError): BaseModel(name="Test") # Missing slug def test_base_model_to_dict() -> None: """Test model serialization to dict.""" model = BaseModel(name="Test", slug="test", desc="Description") data = model.model_dump() assert data["name"] == "Test" assert data["slug"] == "test" assert data["desc"] == "Description" ``` **Step 2: Run test to verify it fails** Run: `pytest tests/test_api_clients/test_models.py -v` Expected: FAIL with "ModuleNotFoundError: No module named 'lorekeeper_mcp.api_clients.models'" **Step 3: Write minimal implementation** Create `src/lorekeeper_mcp/api_clients/models/base.py`: ```python """Base Pydantic models for API responses.""" from pydantic import BaseModel as PydanticBaseModel from pydantic import ConfigDict, Field class BaseModel(PydanticBaseModel): """Base model with common fields for API responses.""" model_config = ConfigDict( populate_by_name=True, str_strip_whitespace=True, use_enum_values=True, ) name: str = Field(..., description="Name of the item") slug: str = Field(..., description="URL-safe identifier") desc: str | None = Field(None, description="Description text") document_url: str | None = Field(None, description="Source document URL") ``` Create `src/lorekeeper_mcp/api_clients/models/__init__.py`: ```python """Pydantic models for API response parsing and validation.""" from lorekeeper_mcp.api_clients.models.base import BaseModel __all__ = ["BaseModel"] ``` **Step 4: Run test to verify it passes** Run: `pytest tests/test_api_clients/test_models.py -v` Expected: PASS (4 tests) **Step 5: Commit** ```bash git add src/lorekeeper_mcp/api_clients/models/__init__.py git add src/lorekeeper_mcp/api_clients/models/base.py git add tests/test_api_clients/test_models.py git commit -m "feat: add base Pydantic model for API responses" ``` --- ## Task 5: Spell Model **Files:** - Create: `src/lorekeeper_mcp/api_clients/models/spell.py` - Modify: `tests/test_api_clients/test_models.py` **Step 1: Write the failing test** Add spell model tests to `test_models.py`: ```python # Add to imports from lorekeeper_mcp.api_clients.models.spell import Spell def test_spell_model_minimal() -> None: """Test Spell model with minimal required fields.""" spell = Spell( name="Fireball", slug="fireball", level=3, school="Evocation", casting_time="1 action", range="150 feet", components="V, S, M", duration="Instantaneous", ) assert spell.name == "Fireball" assert spell.level == 3 assert spell.school == "Evocation" def test_spell_model_full() -> None: """Test Spell model with all fields.""" spell = Spell( name="Fireball", slug="fireball", level=3, school="Evocation", casting_time="1 action", range="150 feet", components="V, S, M", duration="Instantaneous", desc="A bright streak flashes...", higher_level="When you cast this spell using a spell slot of 4th level or higher...", concentration=False, ritual=False, material="A tiny ball of bat guano and sulfur", damage_type=["fire"], ) assert spell.concentration is False assert spell.ritual is False assert spell.material == "A tiny ball of bat guano and sulfur" assert spell.damage_type == ["fire"] def test_spell_cantrip() -> None: """Test Spell model with cantrip (level 0).""" spell = Spell( name="Fire Bolt", slug="fire-bolt", level=0, school="Evocation", casting_time="1 action", range="120 feet", components="V, S", duration="Instantaneous", ) assert spell.level == 0 assert spell.name == "Fire Bolt" ``` **Step 2: Run test to verify it fails** Run: `pytest tests/test_api_clients/test_models.py::test_spell_model_minimal -v` Expected: FAIL with "ImportError: cannot import name 'Spell'" **Step 3: Write minimal implementation** Create `src/lorekeeper_mcp/api_clients/models/spell.py`: ```python """Spell model for D&D 5e spells.""" from pydantic import Field from lorekeeper_mcp.api_clients.models.base import BaseModel class Spell(BaseModel): """Model representing a D&D 5e spell.""" level: int = Field(..., ge=0, le=9, description="Spell level (0-9, 0=cantrip)") school: str = Field(..., description="Magic school (Evocation, Conjuration, etc.)") casting_time: str = Field(..., description="Time required to cast") range: str = Field(..., description="Spell range") components: str = Field(..., description="Components (V, S, M)") duration: str = Field(..., description="Spell duration") concentration: bool = Field(False, description="Requires concentration") ritual: bool = Field(False, description="Can be cast as ritual") material: str | None = Field(None, description="Material components") higher_level: str | None = Field(None, description="Higher level casting effects") damage_type: list[str] | None = Field(None, description="Damage types dealt") ``` Update `src/lorekeeper_mcp/api_clients/models/__init__.py`: ```python """Pydantic models for API response parsing and validation.""" from lorekeeper_mcp.api_clients.models.base import BaseModel from lorekeeper_mcp.api_clients.models.spell import Spell __all__ = ["BaseModel", "Spell"] ``` **Step 4: Run test to verify it passes** Run: `pytest tests/test_api_clients/test_models.py -v` Expected: PASS (all tests including spell tests) **Step 5: Commit** ```bash git add src/lorekeeper_mcp/api_clients/models/spell.py git add src/lorekeeper_mcp/api_clients/models/__init__.py git add tests/test_api_clients/test_models.py git commit -m "feat: add Spell model for spell data" ``` --- ## Task 6: Monster Model **Files:** - Create: `src/lorekeeper_mcp/api_clients/models/monster.py` - Modify: `tests/test_api_clients/test_models.py` **Step 1: Write the failing test** Add monster model tests: ```python # Add to imports from lorekeeper_mcp.api_clients.models.monster import Monster def test_monster_model_minimal() -> None: """Test Monster model with minimal fields.""" monster = Monster( name="Goblin", slug="goblin", size="Small", type="humanoid", alignment="neutral evil", armor_class=15, hit_points=7, hit_dice="2d6", challenge_rating="1/4", ) assert monster.name == "Goblin" assert monster.size == "Small" assert monster.armor_class == 15 assert monster.challenge_rating == "1/4" def test_monster_model_with_stats() -> None: """Test Monster model with ability scores.""" monster = Monster( name="Goblin", slug="goblin", size="Small", type="humanoid", alignment="neutral evil", armor_class=15, hit_points=7, hit_dice="2d6", challenge_rating="1/4", strength=8, dexterity=14, constitution=10, intelligence=10, wisdom=8, charisma=8, ) assert monster.strength == 8 assert monster.dexterity == 14 ``` **Step 2: Run test to verify it fails** Run: `pytest tests/test_api_clients/test_models.py::test_monster_model_minimal -v` Expected: FAIL with "ImportError: cannot import name 'Monster'" **Step 3: Write minimal implementation** Create `src/lorekeeper_mcp/api_clients/models/monster.py`: ```python """Monster model for D&D 5e creatures.""" from pydantic import Field from lorekeeper_mcp.api_clients.models.base import BaseModel class Monster(BaseModel): """Model representing a D&D 5e monster or creature.""" size: str = Field(..., description="Size category (Tiny, Small, Medium, Large, etc.)") type: str = Field(..., description="Creature type (humanoid, beast, dragon, etc.)") alignment: str = Field(..., description="Alignment") armor_class: int = Field(..., ge=0, description="Armor class") hit_points: int = Field(..., ge=0, description="Average hit points") hit_dice: str = Field(..., description="Hit dice formula") challenge_rating: str = Field(..., description="Challenge rating (CR)") # Ability scores strength: int | None = Field(None, ge=1, le=30, description="Strength score") dexterity: int | None = Field(None, ge=1, le=30, description="Dexterity score") constitution: int | None = Field(None, ge=1, le=30, description="Constitution score") intelligence: int | None = Field(None, ge=1, le=30, description="Intelligence score") wisdom: int | None = Field(None, ge=1, le=30, description="Wisdom score") charisma: int | None = Field(None, ge=1, le=30, description="Charisma score") # Optional arrays speed: dict[str, int] | None = Field(None, description="Speed values") actions: list[dict] | None = Field(None, description="Actions") legendary_actions: list[dict] | None = Field(None, description="Legendary actions") special_abilities: list[dict] | None = Field(None, description="Special abilities") ``` Update `src/lorekeeper_mcp/api_clients/models/__init__.py`: ```python """Pydantic models for API response parsing and validation.""" from lorekeeper_mcp.api_clients.models.base import BaseModel from lorekeeper_mcp.api_clients.models.monster import Monster from lorekeeper_mcp.api_clients.models.spell import Spell __all__ = ["BaseModel", "Spell", "Monster"] ``` **Step 4: Run test to verify it passes** Run: `pytest tests/test_api_clients/test_models.py -v` Expected: PASS (all tests) **Step 5: Commit** ```bash git add src/lorekeeper_mcp/api_clients/models/monster.py git add src/lorekeeper_mcp/api_clients/models/__init__.py git add tests/test_api_clients/test_models.py git commit -m "feat: add Monster model for creature data" ``` --- ## Task 7: Equipment Models (Weapon, Armor) **Files:** - Create: `src/lorekeeper_mcp/api_clients/models/equipment.py` - Modify: `tests/test_api_clients/test_models.py` **Step 1: Write the failing test** Add equipment model tests: ```python # Add to imports from lorekeeper_mcp.api_clients.models.equipment import Armor, Weapon def test_weapon_model() -> None: """Test Weapon model.""" weapon = Weapon( name="Longsword", slug="longsword", category="Martial Melee", damage_dice="1d8", damage_type="slashing", cost="15 gp", weight=3.0, ) assert weapon.name == "Longsword" assert weapon.damage_dice == "1d8" assert weapon.damage_type == "slashing" def test_weapon_with_properties() -> None: """Test Weapon with properties array.""" weapon = Weapon( name="Shortbow", slug="shortbow", category="Simple Ranged", damage_dice="1d6", damage_type="piercing", cost="25 gp", weight=2.0, properties=["ammunition", "two-handed"], range_normal=80, range_long=320, ) assert weapon.properties == ["ammunition", "two-handed"] assert weapon.range_normal == 80 assert weapon.range_long == 320 def test_armor_model() -> None: """Test Armor model.""" armor = Armor( name="Chain Mail", slug="chain-mail", category="Heavy", base_ac=16, cost="75 gp", weight=55.0, stealth_disadvantage=True, ) assert armor.name == "Chain Mail" assert armor.category == "Heavy" assert armor.base_ac == 16 assert armor.stealth_disadvantage is True ``` **Step 2: Run test to verify it fails** Run: `pytest tests/test_api_clients/test_models.py::test_weapon_model -v` Expected: FAIL with "ImportError: cannot import name 'Weapon'" **Step 3: Write minimal implementation** Create `src/lorekeeper_mcp/api_clients/models/equipment.py`: ```python """Equipment models for weapons and armor.""" from pydantic import Field from lorekeeper_mcp.api_clients.models.base import BaseModel class Weapon(BaseModel): """Model representing a D&D 5e weapon.""" category: str = Field(..., description="Weapon category (Simple/Martial, Melee/Ranged)") damage_dice: str = Field(..., description="Damage dice (e.g., 1d8)") damage_type: str = Field(..., description="Damage type (slashing, piercing, bludgeoning)") cost: str = Field(..., description="Cost in gold pieces") weight: float = Field(..., ge=0, description="Weight in pounds") properties: list[str] | None = Field(None, description="Weapon properties") range_normal: int | None = Field(None, description="Normal range in feet") range_long: int | None = Field(None, description="Long range in feet") versatile_dice: str | None = Field(None, description="Versatile damage dice") class Armor(BaseModel): """Model representing D&D 5e armor.""" category: str = Field(..., description="Armor category (Light, Medium, Heavy, Shield)") base_ac: int = Field(..., ge=0, description="Base armor class") cost: str = Field(..., description="Cost in gold pieces") weight: float = Field(..., ge=0, description="Weight in pounds") dex_bonus: bool | None = Field(None, description="Can add Dex bonus to AC") max_dex_bonus: int | None = Field(None, description="Maximum Dex bonus") strength_required: int | None = Field(None, description="Minimum Strength required") stealth_disadvantage: bool = Field(False, description="Imposes disadvantage on Stealth") ``` Update `src/lorekeeper_mcp/api_clients/models/__init__.py`: ```python """Pydantic models for API response parsing and validation.""" from lorekeeper_mcp.api_clients.models.base import BaseModel from lorekeeper_mcp.api_clients.models.equipment import Armor, Weapon from lorekeeper_mcp.api_clients.models.monster import Monster from lorekeeper_mcp.api_clients.models.spell import Spell __all__ = ["BaseModel", "Spell", "Monster", "Weapon", "Armor"] ``` **Step 4: Run test to verify it passes** Run: `pytest tests/test_api_clients/test_models.py -v` Expected: PASS (all tests) **Step 5: Commit** ```bash git add src/lorekeeper_mcp/api_clients/models/equipment.py git add src/lorekeeper_mcp/api_clients/models/__init__.py git add tests/test_api_clients/test_models.py git commit -m "feat: add Weapon and Armor models for equipment data" ``` --- ## Task 8: Open5e V2 Client - Core Implementation **Files:** - Create: `src/lorekeeper_mcp/api_clients/open5e_v2.py` - Create: `tests/test_api_clients/test_open5e_v2.py` **Step 1: Write the failing test** ```python """Tests for Open5eV2Client.""" import httpx import pytest import respx from lorekeeper_mcp.api_clients.open5e_v2 import Open5eV2Client @pytest.fixture async def v2_client(test_db) -> Open5eV2Client: """Create Open5eV2Client for testing.""" client = Open5eV2Client() yield client await client.close() @respx.mock async def test_get_spells_basic(v2_client: Open5eV2Client) -> None: """Test basic spell lookup.""" respx.get("https://api.open5e.com/v2/spells/?name=fireball").mock( return_value=httpx.Response( 200, json={ "results": [ { "name": "Fireball", "slug": "fireball", "level": 3, "school": "Evocation", "casting_time": "1 action", "range": "150 feet", "components": "V, S, M", "duration": "Instantaneous", "desc": "A bright streak...", } ] }, ) ) spells = await v2_client.get_spells(name="fireball") assert len(spells) == 1 assert spells[0].name == "Fireball" assert spells[0].level == 3 @respx.mock async def test_get_spells_with_filters(v2_client: Open5eV2Client) -> None: """Test spell lookup with multiple filters.""" respx.get("https://api.open5e.com/v2/spells/?level=3&school=Evocation").mock( return_value=httpx.Response(200, json={"results": []}) ) spells = await v2_client.get_spells(level=3, school="Evocation") assert isinstance(spells, list) ``` **Step 2: Run test to verify it fails** Run: `pytest tests/test_api_clients/test_open5e_v2.py -v` Expected: FAIL with "ModuleNotFoundError: No module named 'lorekeeper_mcp.api_clients.open5e_v2'" **Step 3: Write minimal implementation** Create `src/lorekeeper_mcp/api_clients/open5e_v2.py`: ```python """Client for Open5e API v2 (spells, weapons, armor, etc.).""" from typing import Any from lorekeeper_mcp.api_clients.base import BaseHttpClient from lorekeeper_mcp.api_clients.models.spell import Spell class Open5eV2Client(BaseHttpClient): """Client for Open5e API v2 endpoints.""" def __init__(self, **kwargs: Any) -> None: """Initialize Open5e v2 client. Args: **kwargs: Additional arguments for BaseHttpClient """ super().__init__(base_url="https://api.open5e.com/v2", **kwargs) async def get_spells(self, **filters: Any) -> list[Spell]: """Get spells with optional filters. Args: name: Filter by spell name level: Filter by spell level (0-9) school: Filter by magic school **filters: Additional filter parameters Returns: List of Spell objects """ # Build query parameters params = {k: v for k, v in filters.items() if v is not None} # Make request response = await self.make_request("/spells/", params=params) # Parse results results = response.get("results", []) return [Spell(**spell_data) for spell_data in results] ``` Update `src/lorekeeper_mcp/api_clients/__init__.py`: ```python """API client package for external D&D 5e data sources.""" from lorekeeper_mcp.api_clients.base import BaseHttpClient from lorekeeper_mcp.api_clients.exceptions import ( ApiClientError, ApiError, CacheError, NetworkError, ParseError, ) from lorekeeper_mcp.api_clients.open5e_v2 import Open5eV2Client __all__ = [ "BaseHttpClient", "Open5eV2Client", "ApiClientError", "NetworkError", "ApiError", "ParseError", "CacheError", ] ``` **Step 4: Run test to verify it passes** Run: `pytest tests/test_api_clients/test_open5e_v2.py -v` Expected: PASS (2 tests) **Step 5: Commit** ```bash git add src/lorekeeper_mcp/api_clients/open5e_v2.py git add src/lorekeeper_mcp/api_clients/__init__.py git add tests/test_api_clients/test_open5e_v2.py git commit -m "feat: add Open5eV2Client with spell lookup" ``` --- ## Task 9: Open5e V2 Client - Weapons and Armor **Files:** - Modify: `src/lorekeeper_mcp/api_clients/open5e_v2.py` - Modify: `tests/test_api_clients/test_open5e_v2.py` **Step 1: Write the failing test** Add weapon and armor tests: ```python # Add to imports from lorekeeper_mcp.api_clients.models.equipment import Armor, Weapon @respx.mock async def test_get_weapons(v2_client: Open5eV2Client) -> None: """Test weapon lookup.""" respx.get("https://api.open5e.com/v2/weapons/?name=longsword").mock( return_value=httpx.Response( 200, json={ "results": [ { "name": "Longsword", "slug": "longsword", "category": "Martial Melee", "damage_dice": "1d8", "damage_type": "slashing", "cost": "15 gp", "weight": 3.0, } ] }, ) ) weapons = await v2_client.get_weapons(name="longsword") assert len(weapons) == 1 assert weapons[0].name == "Longsword" assert weapons[0].damage_dice == "1d8" @respx.mock async def test_get_armor(v2_client: Open5eV2Client) -> None: """Test armor lookup.""" respx.get("https://api.open5e.com/v2/armor/?name=chain-mail").mock( return_value=httpx.Response( 200, json={ "results": [ { "name": "Chain Mail", "slug": "chain-mail", "category": "Heavy", "base_ac": 16, "cost": "75 gp", "weight": 55.0, "stealth_disadvantage": True, } ] }, ) ) armors = await v2_client.get_armor(name="chain-mail") assert len(armors) == 1 assert armors[0].name == "Chain Mail" assert armors[0].base_ac == 16 ``` **Step 2: Run test to verify it fails** Run: `pytest tests/test_api_clients/test_open5e_v2.py::test_get_weapons -v` Expected: FAIL with "AttributeError: 'Open5eV2Client' object has no attribute 'get_weapons'" **Step 3: Write minimal implementation** Update `src/lorekeeper_mcp/api_clients/open5e_v2.py`: ```python """Client for Open5e API v2 (spells, weapons, armor, etc.).""" from typing import Any from lorekeeper_mcp.api_clients.base import BaseHttpClient from lorekeeper_mcp.api_clients.models.equipment import Armor, Weapon from lorekeeper_mcp.api_clients.models.spell import Spell class Open5eV2Client(BaseHttpClient): """Client for Open5e API v2 endpoints.""" def __init__(self, **kwargs: Any) -> None: """Initialize Open5e v2 client. Args: **kwargs: Additional arguments for BaseHttpClient """ super().__init__(base_url="https://api.open5e.com/v2", **kwargs) async def get_spells(self, **filters: Any) -> list[Spell]: """Get spells with optional filters. Args: name: Filter by spell name level: Filter by spell level (0-9) school: Filter by magic school **filters: Additional filter parameters Returns: List of Spell objects """ params = {k: v for k, v in filters.items() if v is not None} response = await self.make_request("/spells/", params=params) results = response.get("results", []) return [Spell(**spell_data) for spell_data in results] async def get_weapons(self, **filters: Any) -> list[Weapon]: """Get weapons with optional filters. Args: name: Filter by weapon name category: Filter by weapon category **filters: Additional filter parameters Returns: List of Weapon objects """ params = {k: v for k, v in filters.items() if v is not None} response = await self.make_request("/weapons/", params=params) results = response.get("results", []) return [Weapon(**weapon_data) for weapon_data in results] async def get_armor(self, **filters: Any) -> list[Armor]: """Get armor with optional filters. Args: name: Filter by armor name category: Filter by armor category **filters: Additional filter parameters Returns: List of Armor objects """ params = {k: v for k, v in filters.items() if v is not None} response = await self.make_request("/armor/", params=params) results = response.get("results", []) return [Armor(**armor_data) for armor_data in results] ``` **Step 4: Run test to verify it passes** Run: `pytest tests/test_api_clients/test_open5e_v2.py -v` Expected: PASS (all tests) **Step 5: Commit** ```bash git add src/lorekeeper_mcp/api_clients/open5e_v2.py git add tests/test_api_clients/test_open5e_v2.py git commit -m "feat: add weapon and armor lookup to Open5eV2Client" ``` --- ## Task 10: Open5e V1 Client - Core Implementation **Files:** - Create: `src/lorekeeper_mcp/api_clients/open5e_v1.py` - Create: `tests/test_api_clients/test_open5e_v1.py` **Step 1: Write the failing test** ```python """Tests for Open5eV1Client.""" import httpx import pytest import respx from lorekeeper_mcp.api_clients.open5e_v1 import Open5eV1Client @pytest.fixture async def v1_client(test_db) -> Open5eV1Client: """Create Open5eV1Client for testing.""" client = Open5eV1Client() yield client await client.close() @respx.mock async def test_get_monsters(v1_client: Open5eV1Client) -> None: """Test monster lookup.""" respx.get("https://api.open5e.com/v1/monsters/?name=goblin").mock( return_value=httpx.Response( 200, json={ "results": [ { "name": "Goblin", "slug": "goblin", "size": "Small", "type": "humanoid", "alignment": "neutral evil", "armor_class": 15, "hit_points": 7, "hit_dice": "2d6", "challenge_rating": "1/4", } ] }, ) ) monsters = await v1_client.get_monsters(name="goblin") assert len(monsters) == 1 assert monsters[0].name == "Goblin" assert monsters[0].challenge_rating == "1/4" @respx.mock async def test_get_monsters_by_cr(v1_client: Open5eV1Client) -> None: """Test monster lookup by challenge rating.""" respx.get("https://api.open5e.com/v1/monsters/?challenge_rating=5").mock( return_value=httpx.Response(200, json={"results": []}) ) monsters = await v1_client.get_monsters(challenge_rating="5") assert isinstance(monsters, list) ``` **Step 2: Run test to verify it fails** Run: `pytest tests/test_api_clients/test_open5e_v1.py -v` Expected: FAIL with "ModuleNotFoundError: No module named 'lorekeeper_mcp.api_clients.open5e_v1'" **Step 3: Write minimal implementation** Create `src/lorekeeper_mcp/api_clients/open5e_v1.py`: ```python """Client for Open5e API v1 (monsters, classes, races, magic items).""" from typing import Any from lorekeeper_mcp.api_clients.base import BaseHttpClient from lorekeeper_mcp.api_clients.models.monster import Monster class Open5eV1Client(BaseHttpClient): """Client for Open5e API v1 endpoints.""" def __init__(self, **kwargs: Any) -> None: """Initialize Open5e v1 client. Args: **kwargs: Additional arguments for BaseHttpClient """ super().__init__(base_url="https://api.open5e.com/v1", **kwargs) async def get_monsters(self, **filters: Any) -> list[Monster]: """Get monsters with optional filters. Args: name: Filter by monster name challenge_rating: Filter by CR type: Filter by creature type size: Filter by size **filters: Additional filter parameters Returns: List of Monster objects """ params = {k: v for k, v in filters.items() if v is not None} response = await self.make_request("/monsters/", params=params) results = response.get("results", []) return [Monster(**monster_data) for monster_data in results] ``` Update `src/lorekeeper_mcp/api_clients/__init__.py`: ```python """API client package for external D&D 5e data sources.""" from lorekeeper_mcp.api_clients.base import BaseHttpClient from lorekeeper_mcp.api_clients.exceptions import ( ApiClientError, ApiError, CacheError, NetworkError, ParseError, ) from lorekeeper_mcp.api_clients.open5e_v1 import Open5eV1Client from lorekeeper_mcp.api_clients.open5e_v2 import Open5eV2Client __all__ = [ "BaseHttpClient", "Open5eV1Client", "Open5eV2Client", "ApiClientError", "NetworkError", "ApiError", "ParseError", "CacheError", ] ``` **Step 4: Run test to verify it passes** Run: `pytest tests/test_api_clients/test_open5e_v1.py -v` Expected: PASS (2 tests) **Step 5: Commit** ```bash git add src/lorekeeper_mcp/api_clients/open5e_v1.py git add src/lorekeeper_mcp/api_clients/__init__.py git add tests/test_api_clients/test_open5e_v1.py git commit -m "feat: add Open5eV1Client with monster lookup" ``` --- ## Task 11: Client Factory **Files:** - Create: `src/lorekeeper_mcp/api_clients/factory.py` - Create: `tests/test_api_clients/test_factory.py` **Step 1: Write the failing test** ```python """Tests for ClientFactory.""" import pytest from lorekeeper_mcp.api_clients.factory import ClientFactory from lorekeeper_mcp.api_clients.open5e_v1 import Open5eV1Client from lorekeeper_mcp.api_clients.open5e_v2 import Open5eV2Client def test_create_open5e_v1_client() -> None: """Test factory creates Open5eV1Client.""" client = ClientFactory.create_open5e_v1() assert isinstance(client, Open5eV1Client) assert client.base_url == "https://api.open5e.com/v1" def test_create_open5e_v2_client() -> None: """Test factory creates Open5eV2Client.""" client = ClientFactory.create_open5e_v2() assert isinstance(client, Open5eV2Client) assert client.base_url == "https://api.open5e.com/v2" def test_factory_with_custom_timeout() -> None: """Test factory accepts custom configuration.""" client = ClientFactory.create_open5e_v1(timeout=60.0) assert client.timeout == 60.0 async def test_factory_clients_are_independent() -> None: """Test that factory creates independent client instances.""" client1 = ClientFactory.create_open5e_v1() client2 = ClientFactory.create_open5e_v1() assert client1 is not client2 await client1.close() await client2.close() ``` **Step 2: Run test to verify it fails** Run: `pytest tests/test_api_clients/test_factory.py -v` Expected: FAIL with "ModuleNotFoundError: No module named 'lorekeeper_mcp.api_clients.factory'" **Step 3: Write minimal implementation** Create `src/lorekeeper_mcp/api_clients/factory.py`: ```python """Factory for creating API client instances with dependency injection.""" from typing import Any from lorekeeper_mcp.api_clients.open5e_v1 import Open5eV1Client from lorekeeper_mcp.api_clients.open5e_v2 import Open5eV2Client class ClientFactory: """Factory for creating API client instances.""" @staticmethod def create_open5e_v1(**kwargs: Any) -> Open5eV1Client: """Create an Open5e v1 API client. Args: **kwargs: Configuration options for the client Returns: Configured Open5eV1Client instance """ return Open5eV1Client(**kwargs) @staticmethod def create_open5e_v2(**kwargs: Any) -> Open5eV2Client: """Create an Open5e v2 API client. Args: **kwargs: Configuration options for the client Returns: Configured Open5eV2Client instance """ return Open5eV2Client(**kwargs) ``` Update `src/lorekeeper_mcp/api_clients/__init__.py`: ```python """API client package for external D&D 5e data sources.""" from lorekeeper_mcp.api_clients.base import BaseHttpClient from lorekeeper_mcp.api_clients.exceptions import ( ApiClientError, ApiError, CacheError, NetworkError, ParseError, ) from lorekeeper_mcp.api_clients.factory import ClientFactory from lorekeeper_mcp.api_clients.open5e_v1 import Open5eV1Client from lorekeeper_mcp.api_clients.open5e_v2 import Open5eV2Client __all__ = [ "BaseHttpClient", "ClientFactory", "Open5eV1Client", "Open5eV2Client", "ApiClientError", "NetworkError", "ApiError", "ParseError", "CacheError", ] ``` **Step 4: Run test to verify it passes** Run: `pytest tests/test_api_clients/test_factory.py -v` Expected: PASS (4 tests) **Step 5: Commit** ```bash git add src/lorekeeper_mcp/api_clients/factory.py git add src/lorekeeper_mcp/api_clients/__init__.py git add tests/test_api_clients/test_factory.py git commit -m "feat: add ClientFactory for dependency injection" ``` --- ## Task 12: Run Full Test Suite **Step 1: Run all tests** Run: `pytest tests/test_api_clients/ -v --cov=src/lorekeeper_mcp/api_clients` Expected: PASS (all tests), coverage report **Step 2: Fix any failing tests** If tests fail, debug and fix issues. Commit fixes: ```bash git add <fixed-files> git commit -m "fix: resolve test failures in API clients" ``` **Step 3: Check code quality** Run linting and formatting: ```bash ruff check src/lorekeeper_mcp/api_clients/ ruff format src/lorekeeper_mcp/api_clients/ ``` **Step 4: Commit formatting changes** ```bash git add src/lorekeeper_mcp/api_clients/ git commit -m "style: format API client code" ``` --- ## Remaining Work The following components are **NOT** included in this plan but are in the full tasks.md: ### Not Implemented (for future phases): - Character models (Class, Race, Background, Feat) - Rule models and D&D 5e API client - Open5e v1 classes, races, magic items endpoints - Open5e v2 backgrounds, feats, conditions endpoints - Pagination handling for large result sets - Advanced retry logic with jitter - Request/response interceptors - Integration tests with real API calls - End-to-end testing scenarios - Performance and load testing - Comprehensive documentation and examples **Rationale**: Following YAGNI principle - implementing core infrastructure first (base client, exceptions, basic models, two API clients with key endpoints). Additional endpoints and features can be added incrementally as needed. --- ## Summary This plan implements: 1. ✅ Exception hierarchy 2. ✅ Base HTTP client with caching and retry 3. ✅ Pydantic models (Spell, Monster, Weapon, Armor) 4. ✅ Open5e v2 client (spells, weapons, armor) 5. ✅ Open5e v1 client (monsters) 6. ✅ Client factory 7. ✅ Unit tests with mocks 8. ✅ Code quality checks **Estimated Time**: 2-3 hours for core implementation **Next Steps**: After this plan is complete, additional endpoints and clients can be added following the same TDD pattern established here.

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/frap129/lorekeeper-mcp'

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