From 538b67e57d578b7f46210f3bb526431245c1d8e3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 26 Mar 2026 08:03:31 -0400 Subject: [PATCH] docs(fable-mcp): add server instructions and detailed tool docstrings Adds a comprehensive instructions block to the FastMCP server covering the data model hierarchy, valid enum values, tag format, integer-or-none conventions, when to use fable_send_message vs direct CRUD tools, and admin key requirements. All tool docstrings expanded with full argument descriptions, valid values, and return shape notes. Co-Authored-By: Claude Sonnet 4.6 --- fable-mcp/fable_mcp/server.py | 289 ++++++++++++++++++++++++++++++---- 1 file changed, 255 insertions(+), 34 deletions(-) diff --git a/fable-mcp/fable_mcp/server.py b/fable-mcp/fable_mcp/server.py index bf0a361..87ad547 100644 --- a/fable-mcp/fable_mcp/server.py +++ b/fable-mcp/fable_mcp/server.py @@ -9,7 +9,76 @@ from fable_mcp.tools import notes, tasks, projects, milestones, search, chat, ad load_dotenv() -mcp = FastMCP("fable") +_INSTRUCTIONS = """ +Fable Assistant is a self-hosted second-brain and project management system with LLM integration. + +## Data model + +The hierarchy is: Project → Milestone → Task/Note. + +- **Notes** and **Tasks** share the same underlying model. Tasks are notes with `is_task=True`. + The note tools (fable_*_note) operate on notes; the task tools (fable_*_task) operate on tasks. + Do not use note tools to manipulate tasks or vice versa. + +- **Projects** group related work. A project has a title, description, goal, status, and an + auto-generated summary used for semantic search. Status values: `active`, `archived`. + +- **Milestones** belong to a project and group tasks within it. Status values: `active`, `done`. + +- **Tasks** belong to a project and optionally a milestone. They support sub-tasks via `parent_id`. + - Status values: `todo`, `in_progress`, `done`, `cancelled` + - Priority values: `low`, `normal`, `high` + +- **Notes** are free-form markdown documents. They can belong to a project or be standalone + (orphan notes). Orphan notes are included in the default RAG scope for chat conversations. + +## Tags + +Tags are plain strings — do NOT include a `#` prefix. Example: `["python", "architecture"]`. +Tags are stored as an array on the note/task. Passing `tags=[]` clears all tags; omitting `tags` +leaves existing tags unchanged on updates. + +## Integer-or-none fields + +Due to MCP type constraints, optional integer fields (project_id, milestone_id, parent_id) +use `0` to mean "not set / no association". Pass `0` to leave the field unset. + +## Search + +`fable_search` performs semantic (embedding-based) search over notes and tasks. Use it to find +relevant content by meaning rather than exact keywords. Returns results ranked by cosine +similarity with id, title, a body snippet, and tags. + +## Chat / LLM delegation + +`fable_send_message` sends a natural-language message to Fable's built-in LLM (Ollama). Fable +handles its own tool use, RAG context injection, and conversation history internally. + +Use `fable_send_message` when: +- The request is conversational or requires Fable's internal reasoning across many records +- You want Fable's RAG to surface relevant notes automatically + +Use the direct CRUD tools when: +- You know exactly what to create/read/update/delete +- You need structured data back (IDs, field values) for further processing +- You are populating Fable programmatically from another system + +## Task logs + +Use `fable_add_task_log` to append time-stamped progress notes to a task without overwriting +its main body. Suitable for recording work sessions, decisions, or status updates over time. + +## RSS / Briefing + +Fable runs a daily briefing that summarises tasks, calendar events, and RSS feed items. +Use `fable_add_rss_feed` / `fable_remove_rss_feed` to manage the feeds included in that briefing. + +## Admin logs + +`fable_get_app_logs` requires an admin-scoped API key. Regular user keys will be rejected. +""" + +mcp = FastMCP("fable", instructions=_INSTRUCTIONS) # --------------------------------------------------------------------------- @@ -24,7 +93,13 @@ async def fable_list_notes( tag: str = "", search_text: str = "", ) -> dict: - """List notes stored in Fable. Optionally filter by tag or search text.""" + """List notes (non-task documents) stored in Fable. + + Optionally filter by a single tag (plain string, no # prefix) or a keyword search + against title and body. Results are ordered by last-updated descending. + + Use fable_search for semantic/meaning-based lookup instead of exact keyword search. + """ async with FableClient() as client: return await notes.list_notes( client, @@ -37,7 +112,10 @@ async def fable_list_notes( @mcp.tool() async def fable_get_note(note_id: int) -> dict: - """Fetch the full content of a single Fable note by its ID.""" + """Fetch the full content of a single Fable note by its ID. + + Returns id, title, body (markdown), tags, project_id, created_at, updated_at. + """ async with FableClient() as client: return await notes.get_note(client, note_id=note_id) @@ -49,7 +127,16 @@ async def fable_create_note( tags: list[str] | None = None, project_id: int = 0, ) -> dict: - """Create a new note in Fable.""" + """Create a new note in Fable. + + Args: + title: Note title (required). + body: Markdown content. Supports [[wikilinks]] to other notes by title. + tags: List of plain-string tags without # prefix, e.g. ["python", "ideas"]. + project_id: Associate with a project (use 0 for no project / orphan note). + + Returns the created note object including its assigned id. + """ async with FableClient() as client: return await notes.create_note( client, @@ -68,7 +155,15 @@ async def fable_update_note( tags: list[str] | None = None, project_id: int = 0, ) -> dict: - """Update an existing Fable note. Only provided fields are changed.""" + """Update an existing Fable note. Only explicitly provided fields are changed. + + Args: + note_id: ID of the note to update. + title: New title, or omit to leave unchanged. + body: New markdown body, or omit to leave unchanged. + tags: Replaces the full tag list. Pass [] to clear all tags. Omit to leave unchanged. + project_id: New project association (0 = remove from project). Omit to leave unchanged. + """ async with FableClient() as client: return await notes.update_note( client, @@ -82,7 +177,7 @@ async def fable_update_note( @mcp.tool() async def fable_delete_note(note_id: int) -> str: - """Delete a Fable note by ID.""" + """Permanently delete a Fable note by ID. This cannot be undone.""" async with FableClient() as client: await notes.delete_note(client, note_id=note_id) return f"Note {note_id} deleted." @@ -100,7 +195,14 @@ async def fable_list_tasks( status: str = "", project_id: int = 0, ) -> dict: - """List tasks in Fable. Filter by status (todo/in_progress/done) or project.""" + """List tasks in Fable. + + Args: + status: Filter by status — one of: todo, in_progress, done, cancelled. Omit for all. + project_id: Filter to a specific project. Use 0 for no filter. + + Results are ordered by last-updated descending. + """ async with FableClient() as client: return await tasks.list_tasks( client, @@ -113,7 +215,11 @@ async def fable_list_tasks( @mcp.tool() async def fable_get_task(task_id: int) -> dict: - """Fetch a single Fable task by ID, including parent task title.""" + """Fetch a single Fable task by ID. + + Returns id, title, body, status, priority, tags, project_id, milestone_id, + parent_id, parent_title, due_date, created_at, updated_at. + """ async with FableClient() as client: return await tasks.get_task(client, task_id=task_id) @@ -129,7 +235,20 @@ async def fable_create_task( parent_id: int = 0, tags: list[str] | None = None, ) -> dict: - """Create a new task in Fable.""" + """Create a new task in Fable. + + Args: + title: Task title (required). + body: Markdown description / notes for the task. + status: Initial status — one of: todo (default), in_progress, done, cancelled. + priority: One of: low, normal, high. Omit for no priority. + project_id: Associate with a project (0 = no project). + milestone_id: Place within a project milestone (0 = no milestone). + parent_id: Make this a sub-task of another task (0 = top-level). + tags: List of plain-string tags without # prefix. + + Returns the created task object including its assigned id. + """ async with FableClient() as client: return await tasks.create_task( client, @@ -154,7 +273,17 @@ async def fable_update_task( project_id: int = 0, milestone_id: int = 0, ) -> dict: - """Update an existing Fable task. Only provided fields are changed.""" + """Update an existing Fable task. Only explicitly provided fields are changed. + + Args: + task_id: ID of the task to update. + title: New title, or omit to leave unchanged. + body: New markdown body, or omit to leave unchanged. + status: New status — one of: todo, in_progress, done, cancelled. + priority: New priority — one of: low, normal, high. + project_id: New project (0 = remove from project). Omit to leave unchanged. + milestone_id: New milestone (0 = remove from milestone). Omit to leave unchanged. + """ async with FableClient() as client: return await tasks.update_task( client, @@ -170,7 +299,12 @@ async def fable_update_task( @mcp.tool() async def fable_add_task_log(task_id: int, body: str) -> dict: - """Append a progress log entry to a Fable task.""" + """Append a timestamped progress log entry to a Fable task. + + Use this to record work sessions, decisions, or status updates over time without + overwriting the task's main body. Each entry is stored separately and shown + chronologically in the task view. + """ async with FableClient() as client: return await tasks.add_task_log(client, task_id=task_id, body=body) @@ -182,14 +316,22 @@ async def fable_add_task_log(task_id: int, body: str) -> dict: @mcp.tool() async def fable_list_projects() -> dict: - """List all Fable projects for the current user.""" + """List all Fable projects for the current user. + + Returns id, title, description, goal, status (active/archived), color, + and a short auto-generated summary for each project. + """ 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.""" + """Fetch a Fable project by ID, including its milestone summary. + + Returns full project fields plus a milestone_summary list with each milestone's + id, title, status, and task counts. + """ async with FableClient() as client: return await projects.get_project(client, project_id=project_id) @@ -202,7 +344,17 @@ async def fable_create_project( status: str = "active", color: str = "", ) -> dict: - """Create a new project in Fable.""" + """Create a new project in Fable. + + Args: + title: Project name (required). + description: Short summary of what the project is. + goal: The desired outcome or definition of done for the project. + status: active (default) or archived. + color: Optional hex colour for the project card (e.g. "#6366f1"). + + Returns the created project object including its assigned id. + """ async with FableClient() as client: return await projects.create_project( client, @@ -223,7 +375,16 @@ async def fable_update_project( status: str = "", color: str = "", ) -> dict: - """Update an existing Fable project.""" + """Update an existing Fable project. Only explicitly provided fields are changed. + + Args: + project_id: ID of the project to update. + title: New title, or omit to leave unchanged. + description: New description, or omit to leave unchanged. + goal: New goal/definition-of-done, or omit to leave unchanged. + status: New status — active or archived. + color: New hex colour, or omit to leave unchanged. + """ async with FableClient() as client: return await projects.update_project( client, @@ -243,7 +404,10 @@ async def fable_update_project( @mcp.tool() async def fable_list_milestones(project_id: int) -> dict: - """List milestones for a Fable project.""" + """List milestones for a Fable project, ordered by order_index. + + Returns id, title, description, status (active/done), order_index, and task counts. + """ async with FableClient() as client: return await milestones.list_milestones(client, project_id=project_id) @@ -255,7 +419,16 @@ async def fable_create_milestone( description: str = "", status: str = "active", ) -> dict: - """Create a milestone within a Fable project.""" + """Create a milestone within a Fable project. + + Args: + project_id: The project this milestone belongs to (required). + title: Milestone name (required). + description: Optional description of what this milestone covers. + status: active (default) or done. + + Returns the created milestone including its assigned id. + """ async with FableClient() as client: return await milestones.create_milestone( client, @@ -275,7 +448,16 @@ async def fable_update_milestone( status: str = "", order_index: int = -1, ) -> dict: - """Update a Fable milestone.""" + """Update a Fable milestone. Only explicitly provided fields are changed. + + Args: + project_id: Project the milestone belongs to. + milestone_id: ID of the milestone to update. + title: New title, or omit to leave unchanged. + description: New description, or omit to leave unchanged. + status: New status — active or done. + order_index: New display position (0-based). Use -1 to leave unchanged. + """ async with FableClient() as client: return await milestones.update_milestone( client, @@ -299,10 +481,17 @@ async def fable_search( content_type: str = "all", limit: int = 10, ) -> dict: - """Semantic search over Fable notes and tasks. + """Semantic search over Fable notes and tasks using embedding similarity. - content_type: "note", "task", or "all" (default). - Returns results ranked by similarity with id, title, body snippet, tags. + Finds content by meaning rather than exact keywords. Use this to discover + relevant records when you don't know the exact title or tags. + + Args: + q: Natural-language query string. + content_type: "note", "task", or "all" (default). + limit: Maximum number of results (default 10). + + Returns results ordered by cosine similarity, each with id, title, body snippet, and tags. """ async with FableClient() as client: return await search.search(client, q=q, content_type=content_type, limit=limit) @@ -315,7 +504,11 @@ async def fable_search( @mcp.tool() async def fable_list_conversations(limit: int = 20, offset: int = 0) -> dict: - """List MCP chat conversations stored in Fable.""" + """List chat conversations stored in Fable, ordered by last activity. + + Returns id, title, message_count, created_at, updated_at for each conversation. + Use the id with fable_send_message to continue a specific conversation. + """ async with FableClient() as client: return await chat.list_conversations(client, limit=limit, offset=offset) @@ -326,11 +519,28 @@ async def fable_send_message( conversation_id: str = "", think: bool = False, ) -> dict: - """Send a message to Fable's LLM and receive the full response. + """Send a natural-language message to Fable's built-in 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. + Fable handles tool use, RAG context injection, and conversation history internally. + The LLM can create/update notes and tasks, search, manage projects, and more — all + driven by natural language without you needing to call individual tools. + + Use this when: + - The request is conversational or exploratory + - You want Fable's RAG to automatically surface relevant notes as context + - The task is complex enough to benefit from Fable's internal reasoning + + Use the direct CRUD tools (fable_create_note, etc.) instead when you need + structured data back or are performing bulk/programmatic operations. + + Args: + message: The user message to send. + conversation_id: Continue an existing conversation by passing its id. + Omit to start a new conversation. + think: Enable extended reasoning mode for complex multi-step requests. + + Returns conversation_id (for follow-up messages), the assistant response text, + and a list of any tool_call events that fired during generation. """ async with FableClient() as client: return await chat.send_message( @@ -352,10 +562,15 @@ async def fable_get_app_logs( limit: int = 20, search: str = "", ) -> dict: - """Fetch Fable application logs. Requires an admin API key. + """Fetch Fable application logs. Requires an admin-scoped API key. - category: "error" (default) | "audit" | "usage" - search: optional keyword filter applied to action, endpoint, username, and details + Args: + category: Log category — "error" (default), "audit", or "usage". + limit: Maximum number of log entries to return. + search: Optional keyword filter matched against action, endpoint, username, details. + + Returns a list of log entries ordered by most recent first. + Regular user API keys will receive a 403 — only admin keys are accepted. """ async with FableClient() as client: return await admin.get_app_logs( @@ -373,7 +588,11 @@ async def fable_get_app_logs( @mcp.tool() async def fable_list_rss_feeds() -> dict: - """List all RSS feeds configured in Fable for the current user.""" + """List all RSS/Atom feeds configured in Fable for the current user. + + Returns id, title, url, category, and last_fetched_at for each feed. + These feeds are summarised in the user's daily briefing. + """ async with FableClient() as client: return await briefing.list_rss_feeds(client) @@ -384,12 +603,14 @@ async def fable_add_rss_feed( title: str = "", category: str = "", ) -> dict: - """Add a new RSS feed to Fable. Title is optional — auto-populated from feed metadata. + """Add an RSS or Atom feed to Fable's daily briefing. Args: - url: The RSS/Atom feed URL. - title: Optional display name override. - category: Optional category label (e.g. 'news', 'tech'). + url: The RSS/Atom feed URL (required). + title: Optional display name. If omitted, auto-populated from feed metadata. + category: Optional category label to group feeds (e.g. "news", "tech", "finance"). + + Returns the created feed object including its assigned id. """ async with FableClient() as client: return await briefing.add_rss_feed(