# 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=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: <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

```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)
