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,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()
|
||||
Reference in New Issue
Block a user