feat(fable-mcp): implement FableClient, all tool modules, and server.py
- 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>
This commit is contained in:
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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]
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user