# 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 `scribe` 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`: - `down_revision = "0026"` (the briefing tables migration) - `revision = "0027"` | 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. **Admin routes and API keys:** Routes decorated with `admin_required` check `user.role == "admin"` after auth resolves. API keys authenticate as the key owner — so a write-scoped key for a non-admin user will fail admin-protected routes with 403 (role check, not scope check). This is intentional: API keys cannot elevate privilege beyond the user's role. ### Routes (`/api/api-keys`) New blueprint `api_keys_bp`, registered in `app.py`: - `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=&content_type=note|task|all&limit=N` Calls the existing `semantic_search_notes()` service (already in `services/embeddings.py`). The `content_type` parameter maps to the service's `is_task` argument: - `content_type=note` → `is_task=False` - `content_type=task` → `is_task=True` - `content_type=all` (default) → `is_task=None` 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. ### Conversation Type: `"mcp"` A new conversation type `"mcp"` is added alongside `"chat"` and `"briefing"`. This requires changes in two places in the main app: **`services/chat.py`** — `create_conversation(user_id, title, model)` gains an optional `conversation_type: str = "chat"` parameter, passed through to the `Conversation` constructor. **`routes/chat.py`** — `POST /api/chat/conversations` accepts an optional `conversation_type` body field (defaults to `"chat"`), passed to `create_conversation`. `GET /api/chat/conversations` continues to default-filter to `conversation_type="chat"` (excluding `"mcp"` and `"briefing"`); pass `?type=mcp` to retrieve MCP conversations. **Retention:** `cleanup_old_conversations` in `routes/chat.py` currently deletes conversations regardless of type. It must be updated to exclude `conversation_type="mcp"` from the sweep, so MCP audit-trail conversations are not automatically pruned. MCP conversations: - Are created with a caller-supplied name (e.g., `"MCP Session 2026-03-23"`) or auto-named - Are excluded from the default chat list - Are accessible via `GET /api/chat/conversations?type=mcp` for inspection - Appear in the Fable UI if explicitly navigated to, providing an audit trail of MCP-driven interactions ### Chat SSE Wire Format The MCP's `chat.py` tool must consume the existing SSE stream. The relevant endpoints and event schema: - **Create conversation:** `POST /api/chat/conversations` → `{id, title, ...}` - **Post message + start generation:** `POST /api/chat/conversations/:id/messages` → `{message_id, ...}`; then connect to: - **Stream:** `GET /api/chat/conversations/:id/stream` (SSE) SSE event format (each line is `data: `): - `{"type": "token", "content": "..."}` — streaming token - `{"type": "tool_call", "name": "...", "result": {...}}` — tool fired - `{"type": "done", "content": "...", "tools_used": [...]}` — generation complete; this is the termination event The MCP tool reads tokens until it receives `type: "done"`, then returns `response` (full content) and `tools_used` (list of tool names). --- ## 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 `scribe`. 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 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, delete_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)` - `delete_milestone(project_id, milestone_id)` **`search.py`** tools: - `semantic_search(query, content_type, limit)` — `content_type` is `"note"`, `"task"`, or `"all"` (avoids shadowing Python's `type` builtin). 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 `conversation_type="mcp"` - Posts the message via `POST /api/chat/conversations/:id/messages` - Consumes SSE stream from `GET /api/chat/conversations/:id/stream` until `type: "done"` - Returns: `{response: str, tools_used: [str], conversation_id: int}` - MCP conversations 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. --- ## Build & Repo Plan 1. Implement and test within `scribe/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)