b255a0f90e
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>
272 lines
12 KiB
Markdown
272 lines
12 KiB
Markdown
# 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)
|