359cb24d9a
- client.py: async httpx wrapper, FableAPIError, stream_get() SSE generator, singleton init_client()/get_client() and _reset_client() for tests - tools/: notes, tasks, projects, milestones, search, chat — thin async functions that accept FableClient and call Fable REST endpoints - server.py: FastMCP entry point with 20 tools registered via @mcp.tool(), each opening a fresh FableClient context per call; validates env vars at startup - tests: 34 tests covering client HTTP behaviour, error handling, singleton, SSE streaming, and all tool modules Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
125 lines
4.0 KiB
Python
125 lines
4.0 KiB
Python
"""Async HTTP client for the Fable Assistant API."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import Any, AsyncIterator
|
|
|
|
import httpx
|
|
|
|
|
|
class FableAPIError(Exception):
|
|
"""Raised when the Fable API returns a non-2xx response."""
|
|
|
|
def __init__(self, status_code: int, message: str) -> None:
|
|
self.status_code = status_code
|
|
super().__init__(f"Fable API error {status_code}: {message}")
|
|
|
|
|
|
class FableClient:
|
|
"""Async wrapper around httpx for the Fable REST API."""
|
|
|
|
def __init__(self) -> None:
|
|
url = os.environ.get("FABLE_URL", "").rstrip("/")
|
|
key = os.environ.get("FABLE_API_KEY", "")
|
|
if not url:
|
|
raise ValueError("FABLE_URL environment variable is required")
|
|
if not key:
|
|
raise ValueError("FABLE_API_KEY environment variable is required")
|
|
self.base_url = url
|
|
self._headers = {
|
|
"Authorization": f"Bearer {key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
self._client: httpx.AsyncClient | None = None
|
|
|
|
async def __aenter__(self) -> "FableClient":
|
|
self._client = httpx.AsyncClient(
|
|
base_url=self.base_url,
|
|
headers=self._headers,
|
|
timeout=30.0,
|
|
)
|
|
return self
|
|
|
|
async def __aexit__(self, *args: Any) -> None:
|
|
if self._client:
|
|
await self._client.aclose()
|
|
self._client = None
|
|
|
|
def _http(self) -> httpx.AsyncClient:
|
|
if self._client is None:
|
|
raise RuntimeError("FableClient must be used as an async context manager")
|
|
return self._client
|
|
|
|
@staticmethod
|
|
def _raise_for_status(response: httpx.Response) -> None:
|
|
if response.is_error:
|
|
try:
|
|
message = response.json().get("error", response.text)
|
|
except Exception:
|
|
message = response.text
|
|
raise FableAPIError(response.status_code, message)
|
|
|
|
async def get(self, path: str, **kwargs: Any) -> Any:
|
|
response = await self._http().get(path, **kwargs)
|
|
self._raise_for_status(response)
|
|
return response.json()
|
|
|
|
async def post(self, path: str, **kwargs: Any) -> Any:
|
|
response = await self._http().post(path, **kwargs)
|
|
self._raise_for_status(response)
|
|
return response.json()
|
|
|
|
async def patch(self, path: str, **kwargs: Any) -> Any:
|
|
response = await self._http().patch(path, **kwargs)
|
|
self._raise_for_status(response)
|
|
return response.json()
|
|
|
|
async def put(self, path: str, **kwargs: Any) -> Any:
|
|
response = await self._http().put(path, **kwargs)
|
|
self._raise_for_status(response)
|
|
return response.json()
|
|
|
|
async def delete(self, path: str, **kwargs: Any) -> Any:
|
|
response = await self._http().delete(path, **kwargs)
|
|
if response.status_code == 204:
|
|
return None
|
|
self._raise_for_status(response)
|
|
if response.content:
|
|
return response.json()
|
|
return None
|
|
|
|
async def stream_get(self, path: str, **kwargs: Any) -> AsyncIterator[str]:
|
|
"""Yield non-empty lines from a streaming GET response (SSE)."""
|
|
async with self._http().stream("GET", path, **kwargs) as response:
|
|
self._raise_for_status(response)
|
|
async for line in response.aiter_lines():
|
|
if line:
|
|
yield line
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Module-level singleton
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_client: FableClient | None = None
|
|
|
|
|
|
def init_client() -> FableClient:
|
|
"""Initialise the module-level FableClient singleton (reads env vars)."""
|
|
global _client
|
|
_client = FableClient()
|
|
return _client
|
|
|
|
|
|
def get_client() -> FableClient:
|
|
"""Return the singleton client; raises RuntimeError if not initialised."""
|
|
if _client is None:
|
|
raise RuntimeError("Call init_client() before get_client()")
|
|
return _client
|
|
|
|
|
|
def _reset_client() -> None:
|
|
"""Reset the singleton — used by tests only."""
|
|
global _client
|
|
_client = None
|