Renames src/fabledassistant -> src/scribe and all imports, plus the default DB name and DB user/password (fabled -> scribe) in config + compose. 952 refs / 154 files. Reverses the old 'internal name stays fabledassistant' convention. Code-only: live databases are still physically named 'fabledassistant'. Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git host (fabledsword), MCP (fabled-git) and the image name (fabledscribe) are intentionally unchanged. ruff check src/ clean locally; CI (typecheck + pytest) is the gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12 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
scribeproject 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:
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 <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.
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 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>&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=Falsecontent_type=task→is_task=Truecontent_type=all(default) →is_task=None
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.
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=mcpfor 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: <json>):
{"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
[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)delete_milestone(project_id, milestone_id)
search.py tools:
semantic_search(query, content_type, limit)—content_typeis"note","task", or"all"(avoids shadowing Python'stypebuiltin). 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
conversation_type="mcp" - Posts the message via
POST /api/chat/conversations/:id/messages - Consumes SSE stream from
GET /api/chat/conversations/:id/streamuntiltype: "done" - Returns:
{response: str, tools_used: [str], conversation_id: int} - MCP conversations are excluded from the normal chat UI list
- Looks up or creates a Fable conversation with the given name and
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.
Build & Repo Plan
- Implement and test within
scribe/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)