diff --git a/docs/2026-03-23-fable-mcp-design.md b/docs/2026-03-23-fable-mcp-design.md deleted file mode 100644 index 3edeeb25..00000000 --- a/docs/2026-03-23-fable-mcp-design.md +++ /dev/null @@ -1,271 +0,0 @@ -# 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 ` 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=&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: `): -- `{"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) diff --git a/docs/2026-03-23-fable-mcp-plan.md b/docs/2026-03-23-fable-mcp-plan.md deleted file mode 100644 index fa061a42..00000000 --- a/docs/2026-03-23-fable-mcp-plan.md +++ /dev/null @@ -1,2091 +0,0 @@ -# Fable MCP Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add API key auth to Fabled Assistant and build a standalone Python MCP server that gives Claude full CRUD access to notes, tasks, projects, milestones, semantic search, and chat. - -**Architecture:** Phase 1 adds bearer token auth to the existing Quart app (new `api_keys` table, updated `_check_auth`, settings UI tab). Phase 2 is a standalone `fable-mcp/` Python package using the `mcp[cli]` SDK that calls the Fable HTTP API via `httpx`. The two phases are sequential — Phase 2 can be built/tested independently using a write-scoped API key once Phase 1 is done. - -**Deployment decision:** `fable-mcp/` stays permanently inside the `scribe` repo. It will always be versioned alongside the backend it targets. Future Task A will serve the package from the running Fable Docker image so users can install it directly from their instance. - -**Tech Stack:** Python 3.12, Quart (Phase 1); `mcp[cli]`, `httpx`, `python-dotenv` (Phase 2); Vue 3 + TypeScript (Settings UI); pytest for both. - -**Testing convention:** Phase 1 tests run via `docker compose run --rm app pytest tests/ -v`. Phase 2 tests run locally with `cd fable-mcp && pytest -v`. All Phase 1 tests are unit tests of pure functions — mock DB calls with `unittest.mock.AsyncMock`; never connect to a real database in tests. - -**Important discovery:** `Conversation.conversation_type` column already exists in the model (`models/conversation.py:23`) and `list_conversations` already filters by it (`services/chat.py:49`). No migration needed for conversation types — only service/route wiring. - ---- - -## Phase 1: Fable API Key Feature - -### File Map - -| Action | File | Purpose | -|--------|------|---------| -| Create | `alembic/versions/0027_add_api_keys.py` | DB migration for `api_keys` table | -| Create | `src/scribe/models/api_key.py` | `ApiKey` SQLAlchemy model | -| Modify | `src/scribe/models/__init__.py` | Export `ApiKey` | -| Create | `src/scribe/services/api_keys.py` | create/list/revoke/lookup service functions | -| Modify | `src/scribe/auth.py` | Add bearer token check before session fallback | -| Create | `src/scribe/routes/api_keys.py` | GET/POST/DELETE `/api/api-keys` blueprint | -| Modify | `src/scribe/app.py` | Register `api_keys_bp` and `search_bp` | -| Modify | `src/scribe/services/chat.py:17-30` | Add `conversation_type` param to `create_conversation` | -| Modify | `src/scribe/services/chat.py:123-135` | Exclude `"mcp"` type from `cleanup_old_conversations` | -| Modify | `src/scribe/routes/chat.py:73-79` | Pass `conversation_type` from POST body | -| Create | `src/scribe/routes/search.py` | `GET /api/search` semantic search endpoint | -| Modify | `frontend/src/views/SettingsView.vue` | Add "API Keys" tab | -| Create | `tests/test_api_keys.py` | Unit tests for service + auth | -| Create | `tests/test_search_route.py` | Unit test for search endpoint | - ---- - -### Task 1: ApiKey model + migration - -**Files:** -- Create: `src/scribe/models/api_key.py` -- Create: `alembic/versions/0027_add_api_keys.py` -- Modify: `src/scribe/models/__init__.py` - -- [ ] **Step 1: Write the model** - -Create `src/scribe/models/api_key.py`: - -```python -from datetime import datetime, timezone - -from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text -from sqlalchemy.orm import Mapped, mapped_column - -from scribe.models import Base -from scribe.models.base import CreatedAtMixin - - -class ApiKey(Base, CreatedAtMixin): - __tablename__ = "api_keys" - - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False - ) - name: Mapped[str] = mapped_column(Text, nullable=False) - key_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True) - key_prefix: Mapped[str] = mapped_column(Text, nullable=False) - scope: Mapped[str] = mapped_column(Text, nullable=False) # "read" or "write" - last_used_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - revoked_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - - __table_args__ = ( - Index("ix_api_keys_user_id", "user_id"), - Index("ix_api_keys_key_hash", "key_hash"), - ) - - def to_dict(self) -> dict: - return { - "id": self.id, - "name": self.name, - "key_prefix": self.key_prefix, - "scope": self.scope, - "last_used_at": self.last_used_at.isoformat() if self.last_used_at else None, - "created_at": self.created_at.isoformat(), - "revoked_at": self.revoked_at.isoformat() if self.revoked_at else None, - } -``` - -- [ ] **Step 2: Export from models __init__** - -In `src/scribe/models/__init__.py`, add after the last import line: - -```python -from scribe.models.api_key import ApiKey # noqa: E402, F401 -``` - -- [ ] **Step 3: Write the migration** - -Create `alembic/versions/0027_add_api_keys.py`: - -```python -"""add api_keys table - -Revision ID: 0027 -Revises: 0026 -Create Date: 2026-03-23 -""" -from alembic import op -import sqlalchemy as sa - -revision = "0027" -down_revision = "0026" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.create_table( - "api_keys", - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), - sa.Column("name", sa.Text(), nullable=False), - sa.Column("key_hash", sa.Text(), nullable=False, unique=True), - sa.Column("key_prefix", sa.Text(), nullable=False), - sa.Column("scope", sa.Text(), nullable=False), - sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), - ) - op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"]) - op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"]) - - -def downgrade() -> None: - op.drop_table("api_keys") -``` - -- [ ] **Step 4: Verify migration runs** - -```bash -docker compose run --rm app alembic upgrade head -``` - -Expected: `Running upgrade 0026 -> 0027, add api_keys table` - -- [ ] **Step 5: Commit** - -```bash -git add src/scribe/models/api_key.py \ - src/scribe/models/__init__.py \ - alembic/versions/0027_add_api_keys.py -git commit -m "feat: add ApiKey model and migration 0027" -``` - ---- - -### Task 2: ApiKey service - -**Files:** -- Create: `src/scribe/services/api_keys.py` -- Create: `tests/test_api_keys.py` (service tests) - -- [ ] **Step 1: Write the failing tests** - -Create `tests/test_api_keys.py`: - -```python -"""Unit tests for api_keys service — pure logic, DB calls mocked.""" -import hashlib -from datetime import datetime, timezone -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from scribe.services.api_keys import ( - _hash_key, - generate_key, - create_api_key, - list_api_keys, - revoke_api_key, - lookup_key, -) - - -def test_generate_key_format(): - key = generate_key() - assert key.startswith("fmcp_") - assert len(key) > 12 # prefix + at least some random chars - - -def test_generate_key_uniqueness(): - keys = {generate_key() for _ in range(100)} - assert len(keys) == 100 # no duplicates - - -def test_hash_key_is_sha256(): - key = "fmcp_testkey" - h = _hash_key(key) - expected = hashlib.sha256(key.encode()).hexdigest() - assert h == expected - - -def test_generate_key_prefix(): - key = "fmcp_abcdefghijklmnop" - # prefix is first 12 chars of the full key - from scribe.services.api_keys import _key_prefix - assert _key_prefix(key) == "fmcp_abcdefg" # first 12 chars - - -@pytest.mark.asyncio -async def test_create_api_key_returns_full_key(): - mock_key_obj = MagicMock() - mock_key_obj.id = 1 - mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"} - - with patch("scribe.services.api_keys.async_session") as mock_session_ctx: - mock_session = AsyncMock() - mock_session.__aenter__ = AsyncMock(return_value=mock_session) - mock_session.__aexit__ = AsyncMock(return_value=False) - mock_session.add = MagicMock() - mock_session.commit = AsyncMock() - mock_session_ctx.return_value = mock_session - - full_key, key_dict = await create_api_key(user_id=1, name="test", scope="read") - - assert full_key.startswith("fmcp_") - assert isinstance(key_dict, dict) - - -@pytest.mark.asyncio -async def test_lookup_key_returns_none_for_unknown(): - with patch("scribe.services.api_keys.async_session") as mock_session_ctx: - mock_session = AsyncMock() - mock_session.__aenter__ = AsyncMock(return_value=mock_session) - mock_session.__aexit__ = AsyncMock(return_value=False) - mock_result = MagicMock() - mock_result.scalars.return_value.first.return_value = None - mock_session.execute = AsyncMock(return_value=mock_result) - mock_session_ctx.return_value = mock_session - - result = await lookup_key("fmcp_doesnotexist") - - assert result is None -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -docker compose run --rm app pytest tests/test_api_keys.py -v -``` - -Expected: `ImportError` or `ModuleNotFoundError` (service doesn't exist yet) - -- [ ] **Step 3: Write the service** - -Create `src/scribe/services/api_keys.py`: - -```python -import hashlib -import secrets -from datetime import datetime, timezone - -from sqlalchemy import select - -from scribe.models import async_session -from scribe.models.api_key import ApiKey - - -def generate_key() -> str: - """Generate a new full API key. Never stored — caller must hash it.""" - return "fmcp_" + secrets.token_urlsafe(32) - - -def _hash_key(key: str) -> str: - return hashlib.sha256(key.encode()).hexdigest() - - -def _key_prefix(key: str) -> str: - """Return first 12 chars of the key for display (e.g. 'fmcp_abcdefg').""" - return key[:12] - - -async def create_api_key( - user_id: int, name: str, scope: str -) -> tuple[str, dict]: - """Create a new API key. Returns (full_key, key_dict). full_key is never stored.""" - if scope not in ("read", "write"): - raise ValueError("scope must be 'read' or 'write'") - full_key = generate_key() - key = ApiKey( - user_id=user_id, - name=name, - key_hash=_hash_key(full_key), - key_prefix=_key_prefix(full_key), - scope=scope, - ) - async with async_session() as session: - session.add(key) - await session.commit() - await session.refresh(key) - return full_key, key.to_dict() - - -async def list_api_keys(user_id: int) -> list[dict]: - """List all non-revoked API keys for the user.""" - async with async_session() as session: - result = await session.execute( - select(ApiKey) - .where(ApiKey.user_id == user_id, ApiKey.revoked_at.is_(None)) - .order_by(ApiKey.created_at.desc()) - ) - return [k.to_dict() for k in result.scalars().all()] - - -async def revoke_api_key(user_id: int, key_id: int) -> bool: - """Soft-delete a key by setting revoked_at. Returns True if found.""" - async with async_session() as session: - result = await session.execute( - select(ApiKey).where(ApiKey.id == key_id, ApiKey.user_id == user_id) - ) - key = result.scalars().first() - if key is None: - return False - key.revoked_at = datetime.now(timezone.utc) - await session.commit() - return True - - -async def lookup_key(raw_key: str) -> ApiKey | None: - """Look up a non-revoked ApiKey by raw token value. Updates last_used_at.""" - key_hash = _hash_key(raw_key) - async with async_session() as session: - result = await session.execute( - select(ApiKey).where( - ApiKey.key_hash == key_hash, - ApiKey.revoked_at.is_(None), - ) - ) - key = result.scalars().first() - if key is None: - return None - key.last_used_at = datetime.now(timezone.utc) - await session.commit() - await session.refresh(key) - return key -``` - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -docker compose run --rm app pytest tests/test_api_keys.py -v -``` - -Expected: all tests pass - -- [ ] **Step 5: Commit** - -```bash -git add src/scribe/services/api_keys.py tests/test_api_keys.py -git commit -m "feat: add ApiKey service with create/list/revoke/lookup" -``` - ---- - -### Task 3: Auth middleware — bearer token support - -**Files:** -- Modify: `src/scribe/auth.py` -- Modify: `tests/test_api_keys.py` (add auth middleware tests) - -- [ ] **Step 1: Add auth middleware tests** - -Append to `tests/test_api_keys.py`: - -```python -def test_hash_key_deterministic(): - """Same input always produces same hash (needed for lookup).""" - key = "fmcp_some_test_key_value" - assert _hash_key(key) == _hash_key(key) - - -def test_scope_validation(): - with pytest.raises(ValueError, match="scope must be"): - import asyncio - asyncio.run(create_api_key(1, "bad", "superadmin")) - - -# --- Auth middleware tests --- - -@pytest.mark.asyncio -async def test_bearer_token_path_sets_g_user(monkeypatch): - """Valid bearer token authenticates and sets g.user and g.api_key.""" - from unittest.mock import AsyncMock, MagicMock - from scribe.auth import _check_auth - - # Mock ApiKey object - fake_key = MagicMock() - fake_key.user_id = 7 - fake_key.scope = "write" - - # Mock User object - fake_user = MagicMock() - fake_user.role = "user" - - monkeypatch.setattr("scribe.auth.lookup_key", AsyncMock(return_value=fake_key)) - monkeypatch.setattr("scribe.auth.get_user_by_id", AsyncMock(return_value=fake_user)) - - called_with_user = {} - - async def view_func(): - from quart import g - called_with_user["user"] = g.user - called_with_user["api_key"] = g.api_key - from quart import jsonify - return jsonify({"ok": True}) - - from quart import Quart - app = Quart(__name__) - - @app.route("/test", methods=["GET"]) - @_check_auth - async def protected(): - return await view_func() - - async with app.test_request_context("/test", method="GET", - headers={"Authorization": "Bearer fmcp_valid"}): - # Just verify _check_auth calls lookup_key with the right token - import scribe.auth as auth_module - auth_module.lookup_key.assert_called_with # callable - - -@pytest.mark.asyncio -async def test_read_only_key_blocked_on_post(): - """Read-only API key returns 403 on non-GET requests.""" - from unittest.mock import AsyncMock, MagicMock - from scribe.auth import _check_auth - from quart import Quart - - fake_key = MagicMock() - fake_key.user_id = 7 - fake_key.scope = "read" - - fake_user = MagicMock() - fake_user.role = "user" - - app = Quart(__name__) - - with ( - app.test_request_context("/test", method="POST", - headers={"Authorization": "Bearer fmcp_readonly"}), - ): - from unittest.mock import patch - with patch("scribe.auth.lookup_key", AsyncMock(return_value=fake_key)), \ - patch("scribe.auth.get_user_by_id", AsyncMock(return_value=fake_user)): - - async def dummy(): - return "ok" - - wrapped = _check_auth(dummy) - from quart import current_app - async with app.app_context(): - resp = await wrapped() - # Should return 403 for read-only key on POST - assert resp[1] == 403 -``` - -- [ ] **Step 2: Run to verify pass** (these are pure logic tests) - -```bash -docker compose run --rm app pytest tests/test_api_keys.py -v -``` - -- [ ] **Step 3: Update auth.py** - -Replace `src/scribe/auth.py` with: - -```python -import functools - -from quart import g, jsonify, request, session - -from scribe.services.auth import get_user_by_id -from scribe.services.api_keys import lookup_key - - -def _check_auth(f, required_role: str | None = None): - @functools.wraps(f) - async def decorated(*args, **kwargs): - # --- Bearer token path --- - auth_header = request.headers.get("Authorization", "") - if auth_header.startswith("Bearer "): - raw_key = auth_header[len("Bearer "):] - api_key = await lookup_key(raw_key) - if api_key is None: - return jsonify({"error": "Invalid or revoked API key"}), 401 - user = await get_user_by_id(api_key.user_id) - if not user: - return jsonify({"error": "User not found"}), 401 - # Scope enforcement: read-only keys cannot mutate - if api_key.scope == "read" and request.method not in ("GET", "HEAD", "OPTIONS"): - return jsonify({"error": "Read-only key cannot perform write operations"}), 403 - # Role check (admin_required routes) - if required_role and user.role != required_role: - return jsonify({"error": "Admin access required"}), 403 - g.user = user - g.api_key = api_key - return await f(*args, **kwargs) - - # --- Session path (unchanged) --- - user_id = session.get("user_id") - if not user_id: - return jsonify({"error": "Authentication required"}), 401 - user = await get_user_by_id(user_id) - if not user: - session.clear() - return jsonify({"error": "Authentication required"}), 401 - if session.get("session_version") != user.session_version: - session.clear() - return jsonify({"error": "Session expired. Please log in again."}), 401 - if required_role and user.role != required_role: - return jsonify({"error": "Admin access required"}), 403 - g.user = user - return await f(*args, **kwargs) - return decorated - - -def login_required(f): - return _check_auth(f) - - -def admin_required(f): - return _check_auth(f, required_role="admin") - - -def get_current_user_id() -> int: - return g.user.id -``` - -- [ ] **Step 4: Run the full test suite to check for regressions** - -```bash -docker compose run --rm app pytest tests/ -v -``` - -Expected: all existing tests still pass - -- [ ] **Step 5: Commit** - -```bash -git add src/scribe/auth.py tests/test_api_keys.py -git commit -m "feat: add bearer token auth to _check_auth, falls back to session" -``` - ---- - -### Task 4: API key routes + app registration - -**Files:** -- Create: `src/scribe/routes/api_keys.py` -- Modify: `src/scribe/app.py` - -- [ ] **Step 1: Write the routes** - -Create `src/scribe/routes/api_keys.py`: - -```python -from quart import Blueprint, jsonify, request - -from scribe.auth import login_required, get_current_user_id -from scribe.services.api_keys import create_api_key, list_api_keys, revoke_api_key - -api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys") - - -@api_keys_bp.route("", methods=["GET"]) -@login_required -async def list_keys_route(): - uid = get_current_user_id() - keys = await list_api_keys(uid) - return jsonify({"api_keys": keys}) - - -@api_keys_bp.route("", methods=["POST"]) -@login_required -async def create_key_route(): - uid = get_current_user_id() - data = await request.get_json(force=True, silent=True) or {} - name = (data.get("name") or "").strip() - scope = (data.get("scope") or "").strip() - - if not name: - return jsonify({"error": "name is required"}), 400 - if scope not in ("read", "write"): - return jsonify({"error": "scope must be 'read' or 'write'"}), 400 - - full_key, key_dict = await create_api_key(uid, name=name, scope=scope) - # Return the full key ONCE — it is never retrievable again - return jsonify({"key": full_key, "api_key": key_dict}), 201 - - -@api_keys_bp.route("/", methods=["DELETE"]) -@login_required -async def revoke_key_route(key_id: int): - uid = get_current_user_id() - deleted = await revoke_api_key(uid, key_id) - if not deleted: - return jsonify({"error": "API key not found"}), 404 - return "", 204 -``` - -- [ ] **Step 2: Register in app.py** - -In `src/scribe/app.py`, add the import alongside the other route imports: - -```python -from scribe.routes.api_keys import api_keys_bp -``` - -And add the registration line after `app.register_blueprint(users_bp)`: - -```python -app.register_blueprint(api_keys_bp) -``` - -- [ ] **Step 3: Smoke test the endpoints** - -```bash -docker compose up -d -# Create a key -curl -s -X POST http://localhost:8080/api/api-keys \ - -H "Content-Type: application/json" \ - -H "Cookie: " \ - -d '{"name":"test","scope":"read"}' -# Expected: {"key": "fmcp_...", "api_key": {...}} - -# Test bearer auth -curl -s http://localhost:8080/api/auth/me \ - -H "Authorization: Bearer fmcp_" -# Expected: {"id": ..., "username": ...} -``` - -- [ ] **Step 4: Commit** - -```bash -git add src/scribe/routes/api_keys.py src/scribe/app.py -git commit -m "feat: add API key CRUD routes and register blueprint" -``` - ---- - -### Task 5: Conversation type wiring - -**Files:** -- Modify: `src/scribe/services/chat.py` (lines 17-30 and 123-135) -- Modify: `src/scribe/routes/chat.py` (lines 73-79) - -Note: `Conversation.conversation_type` already exists in the model. `list_conversations` already filters by `conv_type`. This task only wires up creation and retention exclusion. - -- [ ] **Step 1: Update `create_conversation` service** - -In `src/scribe/services/chat.py`, change the function signature at line 17: - -```python -async def create_conversation( - user_id: int, title: str = "", model: str = "", conversation_type: str = "chat" -) -> Conversation: - async with async_session() as session: - conv = Conversation( - user_id=user_id, - title=title, - model=model, - conversation_type=conversation_type, - ) - session.add(conv) - await session.commit() - result = await session.execute( - select(Conversation) - .options(selectinload(Conversation.messages)) - .where(Conversation.id == conv.id) - ) - return result.scalars().first() -``` - -- [ ] **Step 2: Update `cleanup_old_conversations` to exclude "mcp"** - -In `src/scribe/services/chat.py`, update the WHERE clause at line 130: - -```python -result = await session.execute( - sa_delete(Conversation) - .where( - Conversation.user_id == user_id, - Conversation.updated_at < cutoff, - Conversation.conversation_type != "mcp", # preserve MCP audit trail - ) - .returning(Conversation.id) -) -``` - -- [ ] **Step 3: Update the POST route to accept conversation_type** - -In `src/scribe/routes/chat.py`, update `create_conversation_route` (around line 73): - -```python -@chat_bp.route("/conversations", methods=["POST"]) -@login_required -async def create_conversation_route(): - uid = get_current_user_id() - data = await request.get_json(force=True, silent=True) or {} - title = data.get("title", "") - model = data.get("model", Config.OLLAMA_MODEL) - conversation_type = data.get("conversation_type", "chat") - # Only allow known types to prevent accidental misuse - if conversation_type not in ("chat", "mcp"): - conversation_type = "chat" - conv = await create_conversation(uid, title=title, model=model, conversation_type=conversation_type) - return jsonify(conv.to_dict()), 201 -``` - -- [ ] **Step 4: Run tests** - -```bash -docker compose run --rm app pytest tests/ -v -``` - -Expected: all tests pass - -- [ ] **Step 5: Commit** - -```bash -git add src/scribe/services/chat.py src/scribe/routes/chat.py -git commit -m "feat: wire conversation_type through create_conversation, exclude mcp from retention sweep" -``` - ---- - -### Task 6: Semantic search endpoint - -**Files:** -- Create: `src/scribe/routes/search.py` -- Modify: `src/scribe/app.py` -- Create: `tests/test_search_route.py` - -- [ ] **Step 1: Write the failing test** - -Create `tests/test_search_route.py`: - -```python -"""Unit tests for the search route parameter mapping.""" -import pytest -from unittest.mock import patch, AsyncMock - - -def test_content_type_mapping(): - """Verify content_type string maps to correct is_task value.""" - from scribe.routes.search import _content_type_to_is_task - assert _content_type_to_is_task("note") is False - assert _content_type_to_is_task("task") is True - assert _content_type_to_is_task("all") is None - assert _content_type_to_is_task("unknown") is None # default to all -``` - -- [ ] **Step 2: Run to verify it fails** - -```bash -docker compose run --rm app pytest tests/test_search_route.py -v -``` - -Expected: `ImportError` (module doesn't exist yet) - -- [ ] **Step 3: Write the route** - -Create `src/scribe/routes/search.py`: - -```python -from quart import Blueprint, jsonify, request - -from scribe.auth import login_required, get_current_user_id -from scribe.services.embeddings import semantic_search_notes - -search_bp = Blueprint("search", __name__, url_prefix="/api/search") - - -def _content_type_to_is_task(content_type: str) -> bool | None: - """Map content_type query param to semantic_search_notes is_task arg.""" - if content_type == "note": - return False - if content_type == "task": - return True - return None # "all" or unknown → no filter - - -@search_bp.route("", methods=["GET"]) -@login_required -async def search_route(): - uid = get_current_user_id() - q = (request.args.get("q") or "").strip() - if not q: - return jsonify({"error": "q is required"}), 400 - - content_type = request.args.get("content_type", "all") - limit = min(request.args.get("limit", 10, type=int), 50) - is_task = _content_type_to_is_task(content_type) - - results = await semantic_search_notes( - uid, q, limit=limit, is_task=is_task, threshold=0.3 - ) - return jsonify({ - "results": [ - { - "id": note.id, - "title": note.title, - "body": note.body or "", - "is_task": note.is_task, - "tags": note.tags or [], - "similarity": score, - } - for score, note in results # semantic_search_notes returns list[tuple[float, Note]] - ], - "total": len(results), - }) -``` - -Note: check `services/embeddings.py` to confirm the return type of `semantic_search_notes` — it should return a list of `(Note, float)` tuples. If the signature differs, adjust the destructuring above accordingly. - -- [ ] **Step 4: Register in app.py** - -Add to imports in `src/scribe/app.py`: -```python -from scribe.routes.search import search_bp -``` - -Add registration: -```python -app.register_blueprint(search_bp) -``` - -- [ ] **Step 5: Run tests** - -```bash -docker compose run --rm app pytest tests/test_search_route.py tests/ -v -``` - -Expected: all tests pass - -- [ ] **Step 6: Commit** - -```bash -git add src/scribe/routes/search.py \ - src/scribe/app.py \ - tests/test_search_route.py -git commit -m "feat: add GET /api/search semantic search endpoint" -``` - ---- - -### Task 7: Settings UI — API Keys tab - -**Files:** -- Modify: `frontend/src/views/SettingsView.vue` - -- [ ] **Step 1: Add "apikeys" to VALID_TABS** - -In `SettingsView.vue`, find the `VALID_TABS` array (search for `VALID_TABS`) and add `"apikeys"` to it. - -- [ ] **Step 2: Add sidebar tab button** - -In the settings sidebar tab list, add an "API Keys" tab button alongside the existing ones, following the same pattern as existing tabs (look for the ` - - - -
-

Copy this key now — it will not be shown again.

-
- {{ newKeyValue }} - -
- -
- - -
- - - - - - - - - - - - - - - - - - - -
NameScopePrefixLast Used
{{ key.name }}{{ key.scope }}{{ key.key_prefix }}…{{ key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never' }} - - - Sure? - - - -
-
-

No API keys yet.

- -``` - -- [ ] **Step 4: Add reactive state and methods to the script section** - -In the `