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 <noreply@anthropic.com>
9.6 KiB
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:
- Fable API Key Feature — additions to the main
fabledassistantproject to support bearer token authentication - 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 <token> header before falling back to session auth:
- If header present: hash the token, look up in
api_keyswhererevoked_at IS NULL - If found: update
last_used_at, setg.userandg.api_key - Scope enforcement: if
g.api_key.scope == "read"andrequest.methodis notGET, return 403 - 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 onlyDELETE /api/api-keys/:id— revoke by settingrevoked_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=<query>&type=note|task|all&limit=N
Calls the existing semantic_search_notes() service (already in services/embeddings.py). Returns:
{
"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
[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 summarycreate_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)— callsGET /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
- Looks up or creates a Fable conversation with the given name and type
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:
{
"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=mcpif inspection is needed - Appear in the Fable UI if explicitly navigated to, providing an audit trail of MCP-driven interactions
Build & Repo Plan
- Implement and test within
fabledassistant/fable-mcp/ - Once stable, extract to a new Forgejo repo (
bvandeusen/fable-mcp) - 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)