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>
357 lines
9.7 KiB
Python
357 lines
9.7 KiB
Python
"""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()
|