Files
FabledScribe/docs/2026-03-23-fable-mcp-design.md
bvandeusen b255a0f90e
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 1m14s
refactor: rename package fabledassistant -> scribe (code-only)
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>
2026-06-03 15:48:35 -04:00

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:

  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 <token> 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=<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=noteis_task=False
  • content_type=taskis_task=True
  • content_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.pycreate_conversation(user_id, title, model) gains an optional conversation_type: str = "chat" parameter, passed through to the Conversation constructor.

routes/chat.pyPOST /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: <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 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:

{
  "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)