docs: add Fable MCP design spec
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>
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
# 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 `fabledassistant` 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`:
|
||||
|
||||
| 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.
|
||||
|
||||
### 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 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>&type=note|task|all&limit=N`
|
||||
|
||||
Calls the existing `semantic_search_notes()` service (already in `services/embeddings.py`). 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.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
```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)`
|
||||
|
||||
**`search.py`** tools:
|
||||
- `semantic_search(query, type, limit)` — 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 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
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## 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=mcp` if inspection is needed
|
||||
- Appear in the Fable UI if explicitly navigated to, providing an audit trail of MCP-driven interactions
|
||||
|
||||
---
|
||||
|
||||
## Build & Repo Plan
|
||||
|
||||
1. Implement and test within `fabledassistant/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)
|
||||
Reference in New Issue
Block a user