From f9973e22f13685bbcda545b29da1bc1d32c1b45d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 23 Mar 2026 20:00:26 -0400 Subject: [PATCH] docs: add Fable MCP design spec Design for two sub-projects: API key auth support in fabledassistant (bearer tokens, read/write scope, Settings UI) and a standalone Python MCP server at fable-mcp/ exposing notes, tasks, projects, milestones, semantic search, and chat tools to Claude Code. Co-Authored-By: Claude Sonnet 4.6 --- docs/2026-03-23-fable-mcp-design.md | 239 ++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 docs/2026-03-23-fable-mcp-design.md diff --git a/docs/2026-03-23-fable-mcp-design.md b/docs/2026-03-23-fable-mcp-design.md new file mode 100644 index 0000000..9f05148 --- /dev/null +++ b/docs/2026-03-23-fable-mcp-design.md @@ -0,0 +1,239 @@ +# Fable MCP — Design Spec + +**Date:** 2026-03-23 +**Status:** Approved +**Author:** bvandeusen + Claude + +--- + +## Overview + +A Python MCP (Model Context Protocol) server that lets Claude directly interface with a running Fabled Assistant instance. The goal is two-fold: Claude's stronger reasoning handles planning, documentation, and analysis while Fable remains the authoritative store for notes, tasks, projects, and milestones; and direct API access enables process improvement by letting Claude observe and operate on real data rather than working from descriptions. + +The work is split into two sub-projects: + +1. **Fable API Key Feature** — additions to the main `fabledassistant` project to support bearer token authentication +2. **Fable MCP Server** — a new standalone Python package at `fable-mcp/` in the same repo root + +A third sub-project (Forgejo MCP for CI/CD automation) is planned as a follow-on after the Fable MCP is working. + +--- + +## Sub-project 1: Fable API Key Feature + +### Motivation + +All existing Fable routes use session-based authentication (`session["user_id"]`). The MCP server runs as a local process and cannot maintain a browser session, so a stateless bearer token mechanism is required. API keys are user-scoped and carry a read/write permission level. + +### Database + +New table `api_keys` via migration `0027_add_api_keys.py`: + +| Column | Type | Notes | +|--------|------|-------| +| `id` | Integer PK | | +| `user_id` | Integer FK → users | CASCADE delete | +| `name` | Text | Human-readable label | +| `key_hash` | Text | SHA-256 of the full key, used for lookup | +| `key_prefix` | Text | First 8 chars (e.g. `fmcp_ab12`), shown in UI | +| `scope` | Text | `"read"` or `"write"` | +| `last_used_at` | Timestamp | Nullable, updated on each authenticated request | +| `created_at` | Timestamp | | +| `revoked_at` | Timestamp | Nullable — soft delete | + +Full keys are generated as `fmcp_<32 random url-safe chars>`, returned once at creation, never stored. Only the SHA-256 hash is persisted. + +### Auth Middleware (`auth.py`) + +`_check_auth` is updated to check the `Authorization: Bearer ` header before falling back to session auth: + +1. If header present: hash the token, look up in `api_keys` where `revoked_at IS NULL` +2. If found: update `last_used_at`, set `g.user` and `g.api_key` +3. Scope enforcement: if `g.api_key.scope == "read"` and `request.method` is not `GET`, return 403 +4. If header absent: existing session logic runs unchanged + +Session auth is untouched — no regression risk for the web UI. + +### Routes (`/api/api-keys`) + +New blueprint `api_keys_bp`: + +- `GET /api/api-keys` — list caller's keys (prefix, name, scope, last_used_at, created_at — never the hash or full key) +- `POST /api/api-keys` — create key; body: `{name, scope}`. Returns full key in response **once only** +- `DELETE /api/api-keys/:id` — revoke by setting `revoked_at = now()` + +### Settings UI + +New "API Keys" tab in `SettingsView.vue` (added to `VALID_TABS`): + +- Create form: name input + read/write radio/toggle + "Generate Key" button +- After creation: one-time modal displaying the full key with a copy button and a warning that it will not be shown again +- Keys table: columns for name, scope badge, prefix, last used, revoke button +- Revoke shows inline confirmation before calling DELETE + +### New Search Endpoint + +`GET /api/search?q=&type=note|task|all&limit=N` + +Calls the existing `semantic_search_notes()` service (already in `services/embeddings.py`). Returns: + +```json +{ + "results": [ + {"id": 1, "title": "...", "body": "...", "is_task": false, "similarity": 0.87, "tags": [...]} + ], + "total": 5 +} +``` + +This endpoint is needed by the MCP's `search.py` tool module. It reuses existing infrastructure with no new ML work. + +--- + +## Sub-project 2: Fable MCP Server + +### Location + +`fable-mcp/` at the repository root, alongside `src/`, `frontend/`, `alembic/`. It is **not** part of the main Docker build and has no import relationship with `fabledassistant`. It will be extracted to its own Forgejo repo once stable. + +### Package Structure + +``` +fable-mcp/ + pyproject.toml # entry point: fable-mcp = "fable_mcp.server:main" + README.md + .env.example # FABLE_URL=http://localhost:8080, FABLE_API_KEY=fmcp_... + fable_mcp/ + __init__.py + server.py # FastMCP instance, imports + registers all tool modules + client.py # FableClient: async httpx wrapper, env var config, FableAPIError + tools/ + __init__.py + notes.py # list_notes, get_note, create_note, update_note, delete_note, search_notes + tasks.py # list_tasks, get_task, create_task, update_task, delete_task, + # patch_task_status, add_task_log + projects.py # list_projects, get_project, create_project, update_project, + # delete_project, get_project_summary + milestones.py # list_milestones, get_milestone, create_milestone, update_milestone + search.py # semantic_search — calls GET /api/search + chat.py # send_message — creates/continues conversation, returns response +``` + +### Dependencies + +```toml +[project] +dependencies = [ + "mcp[cli]>=1.0", + "httpx>=0.27", + "python-dotenv>=1.0", +] +``` + +### `client.py` + +`FableClient` is an async context manager wrapping `httpx.AsyncClient`. It reads `FABLE_URL` and `FABLE_API_KEY` from environment variables (with `python-dotenv` fallback to `.env`). All methods are `async def` and return parsed dicts. A `FableAPIError(status_code, message)` exception is raised for any non-2xx response. + +A module-level singleton `_client: FableClient` is initialized at server startup and shared across all tool modules. + +### `server.py` + +Uses `mcp[cli]`'s `FastMCP` class. Imports all tool modules, which register their tools via decorators against the shared `FastMCP` instance. Entry point `main()` calls `mcp.run()` (stdio transport). + +### Tool Modules + +Each module imports `_client` from `client.py` and defines tools as `async def` functions decorated with `@mcp.tool()`. Tool docstrings serve as the MCP tool descriptions visible to Claude. + +**`notes.py`** tools: +- `list_notes(query, tags, project_id, limit, offset)` +- `get_note(note_id)` +- `create_note(title, body, tags, project_id)` +- `update_note(note_id, title, body, tags, project_id)` +- `delete_note(note_id)` + +**`tasks.py`** tools: +- `list_tasks(query, project_id, milestone_id, status, priority, limit, offset)` +- `get_task(task_id)` +- `create_task(title, body, project_id, milestone_id, priority, status)` +- `update_task(task_id, title, body, project_id, milestone_id, priority, status)` +- `delete_task(task_id)` +- `patch_task_status(task_id, status)` +- `add_task_log(task_id, content)` + +**`projects.py`** tools: +- `list_projects()` +- `get_project(project_id)` — includes milestone summary +- `create_project(title, description, goal, color)` +- `update_project(project_id, title, description, goal, status, color)` +- `delete_project(project_id)` + +**`milestones.py`** tools: +- `list_milestones(project_id, status)` +- `get_milestone(project_id, milestone_id)` +- `create_milestone(project_id, title, description, order_index)` +- `update_milestone(project_id, milestone_id, title, description, status, order_index)` + +**`search.py`** tools: +- `semantic_search(query, type, limit)` — calls `GET /api/search`, returns ranked results with similarity scores + +**`chat.py`** tools: +- `send_message(message, conversation_name)`: + - Looks up or creates a Fable conversation with the given name and type `"mcp"` + - Posts the message via `POST /api/chat/conversations/:id/messages` + - Polls the SSE stream until generation completes + - Returns: `{response: str, tools_used: [str], conversation_id: int}` + - MCP conversations use type `"mcp"` and are excluded from the normal chat UI list + +### Error Handling + +`FableAPIError` is caught at each tool boundary and returned as a descriptive string rather than propagating as an exception. This ensures Claude receives a readable error message (e.g., `"Fable API error 403: Read-only key cannot perform write operations."`) rather than a stack trace. Network errors (`httpx.RequestError`) are similarly caught and surfaced as strings. + +Scope enforcement lives entirely in Fable's auth middleware — the MCP does not duplicate it. + +### Claude Code Registration + +After `pip install -e fable-mcp/` (or `uv tool install ./fable-mcp`), add to `~/.claude/settings.json`: + +```json +{ + "mcpServers": { + "fable": { + "command": "fable-mcp", + "env": { + "FABLE_URL": "http://localhost:8080", + "FABLE_API_KEY": "fmcp_your_key_here" + } + } + } +} +``` + +Claude Code spawns the process over stdio automatically. No Docker, no daemon. + +--- + +## Conversation Type: `"mcp"` + +A new conversation type `"mcp"` is added alongside `"chat"` and `"briefing"`. Conversations of this type: +- Are created with a caller-supplied name (e.g., `"MCP Session 2026-03-23"`) or auto-named +- Are excluded from `GET /api/chat/conversations` (the main chat list) by default +- Are accessible via `GET /api/chat/conversations?type=mcp` if inspection is needed +- Appear in the Fable UI if explicitly navigated to, providing an audit trail of MCP-driven interactions + +--- + +## Build & Repo Plan + +1. Implement and test within `fabledassistant/fable-mcp/` +2. Once stable, extract to a new Forgejo repo (`bvandeusen/fable-mcp`) +3. Forgejo MCP (Gitea MCP) added as a second MCP server to automate build/push/config workflows — separate spec when ready + +--- + +## Out of Scope (v1) + +- MCP resources (browsable URI tree) — tools cover all use cases in Claude Code +- Forgejo MCP — follow-on spec +- Rate limiting on API key endpoints +- Key expiry / TTL +- Per-resource scope (e.g., key restricted to one project)