diff --git a/fable-mcp/fable_mcp/client.py b/fable-mcp/fable_mcp/client.py new file mode 100644 index 0000000..feca003 --- /dev/null +++ b/fable-mcp/fable_mcp/client.py @@ -0,0 +1,124 @@ +"""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 diff --git a/fable-mcp/fable_mcp/server.py b/fable-mcp/fable_mcp/server.py new file mode 100644 index 0000000..0d46ad3 --- /dev/null +++ b/fable-mcp/fable_mcp/server.py @@ -0,0 +1,356 @@ +"""Fable MCP server — exposes Fable Assistant as MCP tools via stdio transport.""" +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP +from dotenv import load_dotenv + +from fable_mcp.client import FableClient +from fable_mcp.tools import notes, tasks, projects, milestones, search, chat + +load_dotenv() + +mcp = FastMCP("fable") + + +# --------------------------------------------------------------------------- +# Notes +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def fable_list_notes( + limit: int = 20, + offset: int = 0, + tag: str = "", + search_text: str = "", +) -> dict: + """List notes stored in Fable. Optionally filter by tag or search text.""" + async with FableClient() as client: + return await notes.list_notes( + client, + limit=limit, + offset=offset, + tag=tag or None, + search=search_text or None, + ) + + +@mcp.tool() +async def fable_get_note(note_id: int) -> dict: + """Fetch the full content of a single Fable note by its ID.""" + async with FableClient() as client: + return await notes.get_note(client, note_id=note_id) + + +@mcp.tool() +async def fable_create_note( + title: str, + body: str = "", + tags: list[str] | None = None, + project_id: int = 0, +) -> dict: + """Create a new note in Fable.""" + async with FableClient() as client: + return await notes.create_note( + client, + title=title, + body=body, + tags=tags, + project_id=project_id or None, + ) + + +@mcp.tool() +async def fable_update_note( + note_id: int, + title: str = "", + body: str = "", + tags: list[str] | None = None, + project_id: int = 0, +) -> dict: + """Update an existing Fable note. Only provided fields are changed.""" + async with FableClient() as client: + return await notes.update_note( + client, + note_id=note_id, + title=title or None, + body=body or None, + tags=tags, + project_id=project_id or None, + ) + + +@mcp.tool() +async def fable_delete_note(note_id: int) -> str: + """Delete a Fable note by ID.""" + async with FableClient() as client: + await notes.delete_note(client, note_id=note_id) + return f"Note {note_id} deleted." + + +# --------------------------------------------------------------------------- +# Tasks +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def fable_list_tasks( + limit: int = 20, + offset: int = 0, + status: str = "", + project_id: int = 0, +) -> dict: + """List tasks in Fable. Filter by status (todo/in_progress/done) or project.""" + async with FableClient() as client: + return await tasks.list_tasks( + client, + limit=limit, + offset=offset, + status=status or None, + project_id=project_id or None, + ) + + +@mcp.tool() +async def fable_get_task(task_id: int) -> dict: + """Fetch a single Fable task by ID, including parent task title.""" + async with FableClient() as client: + return await tasks.get_task(client, task_id=task_id) + + +@mcp.tool() +async def fable_create_task( + title: str, + body: str = "", + status: str = "todo", + priority: str = "", + project_id: int = 0, + milestone_id: int = 0, + parent_id: int = 0, + tags: list[str] | None = None, +) -> dict: + """Create a new task in Fable.""" + async with FableClient() as client: + return await tasks.create_task( + client, + title=title, + body=body, + status=status, + priority=priority or None, + project_id=project_id or None, + milestone_id=milestone_id or None, + parent_id=parent_id or None, + tags=tags, + ) + + +@mcp.tool() +async def fable_update_task( + task_id: int, + title: str = "", + body: str = "", + status: str = "", + priority: str = "", + project_id: int = 0, + milestone_id: int = 0, +) -> dict: + """Update an existing Fable task. Only provided fields are changed.""" + async with FableClient() as client: + return await tasks.update_task( + client, + task_id=task_id, + title=title or None, + body=body or None, + status=status or None, + priority=priority or None, + project_id=project_id or None, + milestone_id=milestone_id or None, + ) + + +@mcp.tool() +async def fable_add_task_log(task_id: int, body: str) -> dict: + """Append a progress log entry to a Fable task.""" + async with FableClient() as client: + return await tasks.add_task_log(client, task_id=task_id, body=body) + + +# --------------------------------------------------------------------------- +# Projects +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def fable_list_projects() -> dict: + """List all Fable projects for the current user.""" + async with FableClient() as client: + return await projects.list_projects(client) + + +@mcp.tool() +async def fable_get_project(project_id: int) -> dict: + """Fetch a Fable project by ID, including milestone summary.""" + async with FableClient() as client: + return await projects.get_project(client, project_id=project_id) + + +@mcp.tool() +async def fable_create_project( + title: str, + description: str = "", + goal: str = "", + status: str = "active", + color: str = "", +) -> dict: + """Create a new project in Fable.""" + async with FableClient() as client: + return await projects.create_project( + client, + title=title, + description=description, + goal=goal or None, + status=status, + color=color or None, + ) + + +@mcp.tool() +async def fable_update_project( + project_id: int, + title: str = "", + description: str = "", + goal: str = "", + status: str = "", + color: str = "", +) -> dict: + """Update an existing Fable project.""" + async with FableClient() as client: + return await projects.update_project( + client, + project_id=project_id, + title=title or None, + description=description or None, + goal=goal or None, + status=status or None, + color=color or None, + ) + + +# --------------------------------------------------------------------------- +# Milestones +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def fable_list_milestones(project_id: int) -> dict: + """List milestones for a Fable project.""" + async with FableClient() as client: + return await milestones.list_milestones(client, project_id=project_id) + + +@mcp.tool() +async def fable_create_milestone( + project_id: int, + title: str, + description: str = "", + status: str = "active", +) -> dict: + """Create a milestone within a Fable project.""" + async with FableClient() as client: + return await milestones.create_milestone( + client, + project_id=project_id, + title=title, + description=description, + status=status, + ) + + +@mcp.tool() +async def fable_update_milestone( + project_id: int, + milestone_id: int, + title: str = "", + description: str = "", + status: str = "", + order_index: int = -1, +) -> dict: + """Update a Fable milestone.""" + async with FableClient() as client: + return await milestones.update_milestone( + client, + project_id=project_id, + milestone_id=milestone_id, + title=title or None, + description=description or None, + status=status or None, + order_index=order_index if order_index >= 0 else None, + ) + + +# --------------------------------------------------------------------------- +# Search +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def fable_search( + q: str, + content_type: str = "all", + limit: int = 10, +) -> dict: + """Semantic search over Fable notes and tasks. + + content_type: "note", "task", or "all" (default). + Returns results ranked by similarity with id, title, body snippet, tags. + """ + async with FableClient() as client: + return await search.search(client, q=q, content_type=content_type, limit=limit) + + +# --------------------------------------------------------------------------- +# Chat +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def fable_list_conversations(limit: int = 20, offset: int = 0) -> dict: + """List MCP chat conversations stored in Fable.""" + async with FableClient() as client: + return await chat.list_conversations(client, limit=limit, offset=offset) + + +@mcp.tool() +async def fable_send_message( + message: str, + conversation_id: str = "", + think: bool = False, +) -> dict: + """Send a message to Fable's LLM and receive the full response. + + Fable handles tool use, RAG, and history internally. + Pass conversation_id to continue an existing conversation. + Returns conversation_id, response text, and any tool_call events. + """ + async with FableClient() as client: + return await chat.send_message( + client, + message=message, + conversation_id=conversation_id or None, + think=think, + ) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main() -> None: + # Validate env vars at startup — raises ValueError with a clear message if missing. + FableClient() + mcp.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/fable-mcp/fable_mcp/tools/chat.py b/fable-mcp/fable_mcp/tools/chat.py new file mode 100644 index 0000000..89ff44c --- /dev/null +++ b/fable-mcp/fable_mcp/tools/chat.py @@ -0,0 +1,75 @@ +"""MCP tools for Fable chat — create conversations and stream responses.""" +from __future__ import annotations + +import json +from typing import Any + +from fable_mcp.client import FableClient + + +async def list_conversations( + client: FableClient, + *, + limit: int = 20, + offset: int = 0, +) -> dict[str, Any]: + """List MCP chat conversations.""" + params: dict[str, Any] = {"limit": limit, "offset": offset, "type": "mcp"} + return await client.get("/api/chat/conversations", params=params) + + +async def send_message( + client: FableClient, + *, + message: str, + conversation_id: str | None = None, + think: bool = False, +) -> dict[str, Any]: + """Send a message to Fable and return the full assistant response. + + Creates a new MCP conversation if conversation_id is None. + Streams the SSE response and accumulates token chunks into a single string. + + Returns: + Dict with: + - "conversation_id": str + - "response": str (full assistant message) + - "tool_calls": list of any tool_call events observed + """ + if conversation_id is None: + conv = await client.post( + "/api/chat/conversations", + json={"conversation_type": "mcp"}, + ) + conversation_id = conv["id"] + + # Build SSE stream URL with message as query param + params: dict[str, Any] = {"message": message} + if think: + params["think"] = "true" + + tokens: list[str] = [] + tool_calls: list[Any] = [] + + stream_path = f"/api/chat/conversations/{conversation_id}/stream" + async for raw_line in client.stream_get(stream_path, params=params): + if not raw_line.startswith("data: "): + continue + payload_str = raw_line[len("data: "):] + try: + event = json.loads(payload_str) + except json.JSONDecodeError: + continue + event_type = event.get("type") + if event_type == "token": + tokens.append(event.get("content", "")) + elif event_type == "tool_call": + tool_calls.append(event) + elif event_type == "done": + break + + return { + "conversation_id": conversation_id, + "response": "".join(tokens), + "tool_calls": tool_calls, + } diff --git a/fable-mcp/fable_mcp/tools/milestones.py b/fable-mcp/fable_mcp/tools/milestones.py new file mode 100644 index 0000000..3dbdc6a --- /dev/null +++ b/fable-mcp/fable_mcp/tools/milestones.py @@ -0,0 +1,53 @@ +"""MCP tools for Fable milestones.""" +from __future__ import annotations + +from typing import Any + +from fable_mcp.client import FableClient + + +async def list_milestones(client: FableClient, *, project_id: int) -> dict[str, Any]: + """List milestones for a project.""" + return await client.get(f"/api/projects/{project_id}/milestones") + + +async def create_milestone( + client: FableClient, + *, + project_id: int, + title: str, + description: str = "", + status: str = "active", +) -> dict[str, Any]: + """Create a milestone within a project.""" + payload: dict[str, Any] = { + "title": title, + "description": description, + "status": status, + } + return await client.post(f"/api/projects/{project_id}/milestones", json=payload) + + +async def update_milestone( + client: FableClient, + *, + project_id: int, + milestone_id: int, + title: str | None = None, + description: str | None = None, + status: str | None = None, + order_index: int | None = None, +) -> dict[str, Any]: + """Update an existing milestone.""" + payload: dict[str, Any] = {} + if title is not None: + payload["title"] = title + if description is not None: + payload["description"] = description + if status is not None: + payload["status"] = status + if order_index is not None: + payload["order_index"] = order_index + return await client.patch( + f"/api/projects/{project_id}/milestones/{milestone_id}", json=payload + ) diff --git a/fable-mcp/fable_mcp/tools/notes.py b/fable-mcp/fable_mcp/tools/notes.py new file mode 100644 index 0000000..25cadcd --- /dev/null +++ b/fable-mcp/fable_mcp/tools/notes.py @@ -0,0 +1,72 @@ +"""MCP tools for Fable notes.""" +from __future__ import annotations + +from typing import Any + +from fable_mcp.client import FableClient + + +async def list_notes( + client: FableClient, + *, + limit: int = 20, + offset: int = 0, + tag: str | None = None, + search: str | None = None, +) -> dict[str, Any]: + """List notes with optional filtering.""" + params: dict[str, Any] = {"limit": limit, "offset": offset} + if tag: + params["tag"] = tag + if search: + params["search"] = search + return await client.get("/api/notes", params=params) + + +async def get_note(client: FableClient, *, note_id: int) -> dict[str, Any]: + """Fetch a single note by ID.""" + return await client.get(f"/api/notes/{note_id}") + + +async def create_note( + client: FableClient, + *, + title: str, + body: str = "", + tags: list[str] | None = None, + project_id: int | None = None, +) -> dict[str, Any]: + """Create a new note.""" + payload: dict[str, Any] = {"title": title, "body": body} + if tags is not None: + payload["tags"] = tags + if project_id is not None: + payload["project_id"] = project_id + return await client.post("/api/notes", json=payload) + + +async def update_note( + client: FableClient, + *, + note_id: int, + title: str | None = None, + body: str | None = None, + tags: list[str] | None = None, + project_id: int | None = None, +) -> dict[str, Any]: + """Update an existing note.""" + payload: dict[str, Any] = {} + if title is not None: + payload["title"] = title + if body is not None: + payload["body"] = body + if tags is not None: + payload["tags"] = tags + if project_id is not None: + payload["project_id"] = project_id + return await client.patch(f"/api/notes/{note_id}", json=payload) + + +async def delete_note(client: FableClient, *, note_id: int) -> None: + """Delete a note by ID.""" + await client.delete(f"/api/notes/{note_id}") diff --git a/fable-mcp/fable_mcp/tools/projects.py b/fable-mcp/fable_mcp/tools/projects.py new file mode 100644 index 0000000..bcbbef2 --- /dev/null +++ b/fable-mcp/fable_mcp/tools/projects.py @@ -0,0 +1,59 @@ +"""MCP tools for Fable projects.""" +from __future__ import annotations + +from typing import Any + +from fable_mcp.client import FableClient + + +async def list_projects(client: FableClient) -> dict[str, Any]: + """List all projects for the authenticated user.""" + return await client.get("/api/projects") + + +async def get_project(client: FableClient, *, project_id: int) -> dict[str, Any]: + """Fetch a project by ID (includes milestone summary).""" + return await client.get(f"/api/projects/{project_id}") + + +async def create_project( + client: FableClient, + *, + title: str, + description: str = "", + goal: str | None = None, + status: str = "active", + color: str | None = None, +) -> dict[str, Any]: + """Create a new project.""" + payload: dict[str, Any] = {"title": title, "description": description, "status": status} + if goal is not None: + payload["goal"] = goal + if color is not None: + payload["color"] = color + return await client.post("/api/projects", json=payload) + + +async def update_project( + client: FableClient, + *, + project_id: int, + title: str | None = None, + description: str | None = None, + goal: str | None = None, + status: str | None = None, + color: str | None = None, +) -> dict[str, Any]: + """Update an existing project.""" + payload: dict[str, Any] = {} + if title is not None: + payload["title"] = title + if description is not None: + payload["description"] = description + if goal is not None: + payload["goal"] = goal + if status is not None: + payload["status"] = status + if color is not None: + payload["color"] = color + return await client.patch(f"/api/projects/{project_id}", json=payload) diff --git a/fable-mcp/fable_mcp/tools/search.py b/fable-mcp/fable_mcp/tools/search.py new file mode 100644 index 0000000..dc8f083 --- /dev/null +++ b/fable-mcp/fable_mcp/tools/search.py @@ -0,0 +1,30 @@ +"""MCP tool for semantic search across Fable notes and tasks.""" +from __future__ import annotations + +from typing import Any + +from fable_mcp.client import FableClient + + +async def search( + client: FableClient, + *, + q: str, + content_type: str = "all", + limit: int = 10, +) -> dict[str, Any]: + """Semantic search over notes and/or tasks. + + Args: + q: Search query string. + content_type: One of "note", "task", or "all" (default). + limit: Maximum number of results to return. + + Returns: + Dict with "results" list and "total" count. + Each result has: id, title, body, is_task, tags, similarity. + """ + params: dict[str, Any] = {"q": q, "limit": limit} + if content_type != "all": + params["content_type"] = content_type + return await client.get("/api/search", params=params) diff --git a/fable-mcp/fable_mcp/tools/tasks.py b/fable-mcp/fable_mcp/tools/tasks.py new file mode 100644 index 0000000..d9bb4c2 --- /dev/null +++ b/fable-mcp/fable_mcp/tools/tasks.py @@ -0,0 +1,93 @@ +"""MCP tools for Fable tasks.""" +from __future__ import annotations + +from typing import Any + +from fable_mcp.client import FableClient + + +async def list_tasks( + client: FableClient, + *, + limit: int = 20, + offset: int = 0, + status: str | None = None, + project_id: int | None = None, +) -> dict[str, Any]: + """List tasks with optional filtering.""" + params: dict[str, Any] = {"limit": limit, "offset": offset} + if status: + params["status"] = status + if project_id is not None: + params["project_id"] = project_id + return await client.get("/api/tasks", params=params) + + +async def get_task(client: FableClient, *, task_id: int) -> dict[str, Any]: + """Fetch a single task by ID (includes parent_title).""" + return await client.get(f"/api/tasks/{task_id}") + + +async def create_task( + client: FableClient, + *, + title: str, + body: str = "", + status: str = "todo", + priority: str | None = None, + project_id: int | None = None, + milestone_id: int | None = None, + parent_id: int | None = None, + tags: list[str] | None = None, +) -> dict[str, Any]: + """Create a new task.""" + payload: dict[str, Any] = {"title": title, "body": body, "status": status} + if priority is not None: + payload["priority"] = priority + if project_id is not None: + payload["project_id"] = project_id + if milestone_id is not None: + payload["milestone_id"] = milestone_id + if parent_id is not None: + payload["parent_id"] = parent_id + if tags is not None: + payload["tags"] = tags + return await client.post("/api/tasks", json=payload) + + +async def update_task( + client: FableClient, + *, + task_id: int, + title: str | None = None, + body: str | None = None, + status: str | None = None, + priority: str | None = None, + project_id: int | None = None, + milestone_id: int | None = None, +) -> dict[str, Any]: + """Update an existing task.""" + payload: dict[str, Any] = {} + if title is not None: + payload["title"] = title + if body is not None: + payload["body"] = body + if status is not None: + payload["status"] = status + if priority is not None: + payload["priority"] = priority + if project_id is not None: + payload["project_id"] = project_id + if milestone_id is not None: + payload["milestone_id"] = milestone_id + return await client.patch(f"/api/tasks/{task_id}", json=payload) + + +async def add_task_log( + client: FableClient, + *, + task_id: int, + body: str, +) -> dict[str, Any]: + """Append a log entry to a task.""" + return await client.post(f"/api/tasks/{task_id}/logs", json={"body": body}) diff --git a/fable-mcp/tests/test_client.py b/fable-mcp/tests/test_client.py new file mode 100644 index 0000000..9e5723b --- /dev/null +++ b/fable-mcp/tests/test_client.py @@ -0,0 +1,142 @@ +"""Tests for FableClient.""" +import os +import pytest +import respx +import httpx +from unittest.mock import patch + +from fable_mcp.client import FableClient, FableAPIError, get_client, init_client, _reset_client + + +@pytest.fixture(autouse=True) +def reset_singleton(): + """Reset the module-level client singleton between tests.""" + _reset_client() + yield + _reset_client() + + +@pytest.fixture +def base_env(monkeypatch): + monkeypatch.setenv("FABLE_URL", "http://fable.test") + monkeypatch.setenv("FABLE_API_KEY", "fmcp_testkey") + + +class TestFableClientInit: + def test_missing_fable_url_raises(self, monkeypatch): + monkeypatch.delenv("FABLE_URL", raising=False) + monkeypatch.setenv("FABLE_API_KEY", "fmcp_testkey") + with pytest.raises(ValueError, match="FABLE_URL"): + FableClient() + + def test_missing_api_key_raises(self, monkeypatch): + monkeypatch.setenv("FABLE_URL", "http://fable.test") + monkeypatch.delenv("FABLE_API_KEY", raising=False) + with pytest.raises(ValueError, match="FABLE_API_KEY"): + FableClient() + + def test_trailing_slash_stripped(self, base_env): + with patch.dict(os.environ, {"FABLE_URL": "http://fable.test/"}): + client = FableClient() + assert client.base_url == "http://fable.test" + + def test_auth_header_set(self, base_env): + client = FableClient() + assert client._headers["Authorization"] == "Bearer fmcp_testkey" + + +class TestFableClientHTTP: + @respx.mock + @pytest.mark.asyncio + async def test_get_returns_json(self, base_env): + respx.get("http://fable.test/api/notes").mock( + return_value=httpx.Response(200, json={"notes": []}) + ) + async with FableClient() as client: + data = await client.get("/api/notes") + assert data == {"notes": []} + + @respx.mock + @pytest.mark.asyncio + async def test_non_2xx_raises_fable_api_error(self, base_env): + respx.get("http://fable.test/api/notes").mock( + return_value=httpx.Response(401, json={"error": "Unauthorized"}) + ) + async with FableClient() as client: + with pytest.raises(FableAPIError) as exc_info: + await client.get("/api/notes") + assert exc_info.value.status_code == 401 + + @respx.mock + @pytest.mark.asyncio + async def test_post_sends_json(self, base_env): + respx.post("http://fable.test/api/notes").mock( + return_value=httpx.Response(201, json={"id": 1, "title": "Test"}) + ) + async with FableClient() as client: + data = await client.post("/api/notes", json={"title": "Test"}) + assert data["id"] == 1 + + @respx.mock + @pytest.mark.asyncio + async def test_patch_sends_json(self, base_env): + respx.patch("http://fable.test/api/notes/1").mock( + return_value=httpx.Response(200, json={"id": 1, "title": "Updated"}) + ) + async with FableClient() as client: + data = await client.patch("/api/notes/1", json={"title": "Updated"}) + assert data["title"] == "Updated" + + @respx.mock + @pytest.mark.asyncio + async def test_delete_returns_none_on_204(self, base_env): + respx.delete("http://fable.test/api/notes/1").mock( + return_value=httpx.Response(204) + ) + async with FableClient() as client: + result = await client.delete("/api/notes/1") + assert result is None + + @respx.mock + @pytest.mark.asyncio + async def test_fable_api_error_includes_message(self, base_env): + respx.get("http://fable.test/api/notes").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + async with FableClient() as client: + with pytest.raises(FableAPIError) as exc_info: + await client.get("/api/notes") + assert "Not found" in str(exc_info.value) + assert exc_info.value.status_code == 404 + + +class TestSingleton: + def test_get_client_before_init_raises(self): + with pytest.raises(RuntimeError, match="init_client"): + get_client() + + def test_init_client_returns_instance(self, base_env): + client = init_client() + assert isinstance(client, FableClient) + + def test_get_client_after_init_returns_same(self, base_env): + init_client() + c1 = get_client() + c2 = get_client() + assert c1 is c2 + + +class TestStreamGet: + @respx.mock + @pytest.mark.asyncio + async def test_stream_get_yields_lines(self, base_env): + sse_body = b"data: line1\n\ndata: line2\n\n" + respx.get("http://fable.test/api/stream").mock( + return_value=httpx.Response(200, content=sse_body) + ) + lines = [] + async with FableClient() as client: + async for line in client.stream_get("/api/stream"): + lines.append(line) + assert "data: line1" in lines + assert "data: line2" in lines diff --git a/fable-mcp/tests/test_tools_chat.py b/fable-mcp/tests/test_tools_chat.py new file mode 100644 index 0000000..d8b527d --- /dev/null +++ b/fable-mcp/tests/test_tools_chat.py @@ -0,0 +1,58 @@ +"""Tests for chat MCP tools.""" +import json +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + + +@pytest.fixture +def client(mock_client): + return mock_client + + +@pytest.mark.asyncio +async def test_send_message_creates_conversation_and_streams(client): + from fable_mcp.tools.chat import send_message + + client.post = AsyncMock(return_value={"id": "conv-1"}) + + async def fake_stream(path, **kwargs): + for line in [ + 'data: {"type": "token", "content": "Hello"}', + 'data: {"type": "token", "content": " world"}', + 'data: {"type": "done"}', + ]: + yield line + + client.stream_get = fake_stream + + result = await send_message(client, message="Hi", conversation_id=None) + assert result["conversation_id"] == "conv-1" + assert "Hello world" in result["response"] + + +@pytest.mark.asyncio +async def test_send_message_reuses_existing_conversation(client): + from fable_mcp.tools.chat import send_message + + async def fake_stream(path, **kwargs): + for line in [ + 'data: {"type": "token", "content": "Reply"}', + 'data: {"type": "done"}', + ]: + yield line + + client.stream_get = fake_stream + + result = await send_message(client, message="Hi", conversation_id="existing-conv") + # Should not call post to create a conversation + client.post.assert_not_called() + assert result["conversation_id"] == "existing-conv" + + +@pytest.mark.asyncio +async def test_list_conversations_calls_get(client): + from fable_mcp.tools.chat import list_conversations + client.get = AsyncMock(return_value={"conversations": []}) + await list_conversations(client) + client.get.assert_called_once() + assert "/api/chat/conversations" in client.get.call_args[0][0] diff --git a/fable-mcp/tests/test_tools_milestones.py b/fable-mcp/tests/test_tools_milestones.py new file mode 100644 index 0000000..13f94f3 --- /dev/null +++ b/fable-mcp/tests/test_tools_milestones.py @@ -0,0 +1,35 @@ +"""Tests for milestones MCP tools.""" +import pytest +from unittest.mock import AsyncMock + + +@pytest.fixture +def client(mock_client): + return mock_client + + +@pytest.mark.asyncio +async def test_list_milestones_calls_correct_endpoint(client): + from fable_mcp.tools.milestones import list_milestones + client.get = AsyncMock(return_value={"milestones": []}) + await list_milestones(client, project_id=3) + client.get.assert_called_once_with("/api/projects/3/milestones") + + +@pytest.mark.asyncio +async def test_create_milestone_posts_to_project_endpoint(client): + from fable_mcp.tools.milestones import create_milestone + client.post = AsyncMock(return_value={"id": 10, "title": "M1"}) + await create_milestone(client, project_id=3, title="M1") + path = client.post.call_args[0][0] + assert path == "/api/projects/3/milestones" + assert client.post.call_args[1]["json"]["title"] == "M1" + + +@pytest.mark.asyncio +async def test_update_milestone_patches(client): + from fable_mcp.tools.milestones import update_milestone + client.patch = AsyncMock(return_value={"id": 10, "status": "completed"}) + await update_milestone(client, project_id=3, milestone_id=10, status="completed") + path = client.patch.call_args[0][0] + assert "/api/projects/3/milestones/10" in path diff --git a/fable-mcp/tests/test_tools_notes.py b/fable-mcp/tests/test_tools_notes.py new file mode 100644 index 0000000..a7ba00d --- /dev/null +++ b/fable-mcp/tests/test_tools_notes.py @@ -0,0 +1,56 @@ +"""Tests for notes MCP tools.""" +import pytest +from unittest.mock import AsyncMock + + +@pytest.fixture +def client(mock_client): + return mock_client + + +@pytest.mark.asyncio +async def test_list_notes_calls_get(client): + from fable_mcp.tools.notes import list_notes + client.get = AsyncMock(return_value={"notes": [], "total": 0}) + result = await list_notes(client, limit=10) + client.get.assert_called_once() + call_args = client.get.call_args + assert "/api/notes" in call_args[0][0] + + +@pytest.mark.asyncio +async def test_get_note_calls_correct_endpoint(client): + from fable_mcp.tools.notes import get_note + client.get = AsyncMock(return_value={"id": 42, "title": "Hello"}) + result = await get_note(client, note_id=42) + client.get.assert_called_once_with("/api/notes/42") + assert result["id"] == 42 + + +@pytest.mark.asyncio +async def test_create_note_posts_payload(client): + from fable_mcp.tools.notes import create_note + client.post = AsyncMock(return_value={"id": 1, "title": "My Note"}) + result = await create_note(client, title="My Note", body="Content") + client.post.assert_called_once() + call_kwargs = client.post.call_args[1] + assert call_kwargs["json"]["title"] == "My Note" + assert call_kwargs["json"]["body"] == "Content" + + +@pytest.mark.asyncio +async def test_update_note_patches_payload(client): + from fable_mcp.tools.notes import update_note + client.patch = AsyncMock(return_value={"id": 1, "title": "Updated"}) + result = await update_note(client, note_id=1, title="Updated") + client.patch.assert_called_once() + path = client.patch.call_args[0][0] + assert "/api/notes/1" in path + + +@pytest.mark.asyncio +async def test_delete_note_calls_delete(client): + from fable_mcp.tools.notes import delete_note + client.delete = AsyncMock(return_value=None) + await delete_note(client, note_id=5) + client.delete.assert_called_once_with("/api/notes/5") diff --git a/fable-mcp/tests/test_tools_projects.py b/fable-mcp/tests/test_tools_projects.py new file mode 100644 index 0000000..8cd12e9 --- /dev/null +++ b/fable-mcp/tests/test_tools_projects.py @@ -0,0 +1,42 @@ +"""Tests for projects MCP tools.""" +import pytest +from unittest.mock import AsyncMock + + +@pytest.fixture +def client(mock_client): + return mock_client + + +@pytest.mark.asyncio +async def test_list_projects_calls_get(client): + from fable_mcp.tools.projects import list_projects + client.get = AsyncMock(return_value={"projects": []}) + await list_projects(client) + client.get.assert_called_once_with("/api/projects") + + +@pytest.mark.asyncio +async def test_get_project_calls_correct_endpoint(client): + from fable_mcp.tools.projects import get_project + client.get = AsyncMock(return_value={"id": 2, "title": "Proj"}) + result = await get_project(client, project_id=2) + client.get.assert_called_once_with("/api/projects/2") + + +@pytest.mark.asyncio +async def test_create_project_posts_payload(client): + from fable_mcp.tools.projects import create_project + client.post = AsyncMock(return_value={"id": 4, "title": "New"}) + await create_project(client, title="New", description="Desc") + call_kwargs = client.post.call_args[1] + assert call_kwargs["json"]["title"] == "New" + assert call_kwargs["json"]["description"] == "Desc" + + +@pytest.mark.asyncio +async def test_update_project_patches(client): + from fable_mcp.tools.projects import update_project + client.patch = AsyncMock(return_value={"id": 4, "status": "active"}) + await update_project(client, project_id=4, status="active") + assert "/api/projects/4" in client.patch.call_args[0][0] diff --git a/fable-mcp/tests/test_tools_tasks.py b/fable-mcp/tests/test_tools_tasks.py new file mode 100644 index 0000000..12246d6 --- /dev/null +++ b/fable-mcp/tests/test_tools_tasks.py @@ -0,0 +1,54 @@ +"""Tests for tasks MCP tools.""" +import pytest +from unittest.mock import AsyncMock + + +@pytest.fixture +def client(mock_client): + return mock_client + + +@pytest.mark.asyncio +async def test_list_tasks_calls_get(client): + from fable_mcp.tools.tasks import list_tasks + client.get = AsyncMock(return_value={"tasks": [], "total": 0}) + await list_tasks(client) + client.get.assert_called_once() + assert "/api/tasks" in client.get.call_args[0][0] + + +@pytest.mark.asyncio +async def test_get_task_calls_correct_endpoint(client): + from fable_mcp.tools.tasks import get_task + client.get = AsyncMock(return_value={"id": 7, "title": "Fix bug"}) + result = await get_task(client, task_id=7) + client.get.assert_called_once_with("/api/tasks/7") + + +@pytest.mark.asyncio +async def test_create_task_posts_payload(client): + from fable_mcp.tools.tasks import create_task + client.post = AsyncMock(return_value={"id": 3, "title": "New task"}) + await create_task(client, title="New task") + call_kwargs = client.post.call_args[1] + assert call_kwargs["json"]["title"] == "New task" + + +@pytest.mark.asyncio +async def test_update_task_patches(client): + from fable_mcp.tools.tasks import update_task + client.patch = AsyncMock(return_value={"id": 3, "status": "done"}) + await update_task(client, task_id=3, status="done") + client.patch.assert_called_once() + assert "/api/tasks/3" in client.patch.call_args[0][0] + + +@pytest.mark.asyncio +async def test_add_task_log_posts_to_logs_endpoint(client): + from fable_mcp.tools.tasks import add_task_log + client.post = AsyncMock(return_value={"id": 1, "body": "progress"}) + await add_task_log(client, task_id=9, body="progress") + client.post.assert_called_once() + path = client.post.call_args[0][0] + assert path == "/api/tasks/9/logs" + assert client.post.call_args[1]["json"]["body"] == "progress"