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>
2092 lines
63 KiB
Markdown
2092 lines
63 KiB
Markdown
# 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/<file> -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("/<int:key_id>", 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: <your session 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_<key from above>"
|
|
# 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 `<button>` elements with `@click="activeTab = '...'"` and add a matching one for `"apikeys"`).
|
|
|
|
- [ ] **Step 3: Add tab panel content**
|
|
|
|
Add the API Keys panel inside the `<div class="settings-content">` block, following the existing `v-if="activeTab === '...'"` pattern:
|
|
|
|
```vue
|
|
<!-- API Keys Tab -->
|
|
<div v-if="activeTab === 'apikeys'" class="settings-section">
|
|
<h2>API Keys</h2>
|
|
<p class="settings-description">
|
|
API keys let external tools (like the Fable MCP server) access your data
|
|
without a browser session. Write-scoped keys can create and update content;
|
|
read-only keys can only query.
|
|
</p>
|
|
|
|
<!-- Create form -->
|
|
<div class="api-key-create-form">
|
|
<input
|
|
v-model="newKeyName"
|
|
placeholder="Key name (e.g. Claude MCP)"
|
|
class="settings-input"
|
|
/>
|
|
<div class="api-key-scope-select">
|
|
<label>
|
|
<input type="radio" v-model="newKeyScope" value="read" /> Read-only
|
|
</label>
|
|
<label>
|
|
<input type="radio" v-model="newKeyScope" value="write" /> Read + Write
|
|
</label>
|
|
</div>
|
|
<button @click="createKey" :disabled="!newKeyName || creatingKey" class="btn-primary">
|
|
{{ creatingKey ? 'Generating...' : 'Generate Key' }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- One-time key display modal -->
|
|
<div v-if="newKeyValue" class="api-key-reveal">
|
|
<p><strong>Copy this key now — it will not be shown again.</strong></p>
|
|
<div class="api-key-value-row">
|
|
<code class="api-key-value">{{ newKeyValue }}</code>
|
|
<button @click="copyKey" class="btn-secondary">
|
|
{{ keyCopied ? 'Copied!' : 'Copy' }}
|
|
</button>
|
|
</div>
|
|
<button @click="newKeyValue = ''" class="btn-secondary" style="margin-top: 0.5rem;">
|
|
Done
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Keys table -->
|
|
<div v-if="apiKeys.length > 0" class="api-keys-list">
|
|
<table class="api-keys-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Scope</th>
|
|
<th>Prefix</th>
|
|
<th>Last Used</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="key in apiKeys" :key="key.id">
|
|
<td>{{ key.name }}</td>
|
|
<td><span :class="['scope-badge', key.scope]">{{ key.scope }}</span></td>
|
|
<td><code>{{ key.key_prefix }}…</code></td>
|
|
<td>{{ key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never' }}</td>
|
|
<td>
|
|
<button
|
|
v-if="revokeConfirm !== key.id"
|
|
@click="revokeConfirm = key.id"
|
|
class="btn-danger-outline btn-sm"
|
|
>Revoke</button>
|
|
<span v-else>
|
|
Sure?
|
|
<button @click="revokeKey(key.id)" class="btn-danger btn-sm">Yes</button>
|
|
<button @click="revokeConfirm = null" class="btn-secondary btn-sm">No</button>
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p v-else class="settings-empty">No API keys yet.</p>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 4: Add reactive state and methods to the script section**
|
|
|
|
In the `<script setup>` block, add alongside the existing reactive state:
|
|
|
|
```typescript
|
|
// API Keys
|
|
const apiKeys = ref<Array<{id: number, name: string, scope: string, key_prefix: string, last_used_at: string | null}>>([])
|
|
const newKeyName = ref('')
|
|
const newKeyScope = ref<'read' | 'write'>('write')
|
|
const newKeyValue = ref('')
|
|
const keyCopied = ref(false)
|
|
const creatingKey = ref(false)
|
|
const revokeConfirm = ref<number | null>(null)
|
|
|
|
async function fetchApiKeys() {
|
|
const res = await fetch('/api/api-keys', { credentials: 'include' })
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
apiKeys.value = data.api_keys
|
|
}
|
|
}
|
|
|
|
async function createKey() {
|
|
if (!newKeyName.value) return
|
|
creatingKey.value = true
|
|
try {
|
|
const res = await fetch('/api/api-keys', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: newKeyName.value, scope: newKeyScope.value }),
|
|
})
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
newKeyValue.value = data.key
|
|
newKeyName.value = ''
|
|
await fetchApiKeys()
|
|
}
|
|
} finally {
|
|
creatingKey.value = false
|
|
}
|
|
}
|
|
|
|
async function revokeKey(id: number) {
|
|
await fetch(`/api/api-keys/${id}`, { method: 'DELETE', credentials: 'include' })
|
|
revokeConfirm.value = null
|
|
await fetchApiKeys()
|
|
}
|
|
|
|
async function copyKey() {
|
|
await navigator.clipboard.writeText(newKeyValue.value)
|
|
keyCopied.value = true
|
|
setTimeout(() => { keyCopied.value = false }, 2000)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Fetch keys when tab activates**
|
|
|
|
Find the existing `watch(activeTab, ...)` or `onMounted` logic and add a call to `fetchApiKeys()` when `activeTab` becomes `"apikeys"`. If there's no watch on activeTab, add:
|
|
|
|
```typescript
|
|
watch(activeTab, (tab) => {
|
|
if (tab === 'apikeys') fetchApiKeys()
|
|
})
|
|
```
|
|
|
|
If a watch already exists, add the `apikeys` case to it.
|
|
|
|
- [ ] **Step 6: TypeScript check**
|
|
|
|
```bash
|
|
docker compose run --rm app sh -c "cd /app/frontend && npx tsc --noEmit"
|
|
```
|
|
|
|
Expected: no errors
|
|
|
|
- [ ] **Step 7: Build and smoke test in browser**
|
|
|
|
```bash
|
|
docker compose up --build -d
|
|
```
|
|
|
|
Navigate to Settings → API Keys. Create a key, copy it, verify it appears in the table. Test the bearer token with curl.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/views/SettingsView.vue
|
|
git commit -m "feat: add API Keys tab to Settings with create/revoke UI"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2: Fable MCP Server
|
|
|
|
### File Map
|
|
|
|
| Action | File | Purpose |
|
|
|--------|------|---------|
|
|
| Create | `fable-mcp/pyproject.toml` | Package definition, entry point, deps |
|
|
| Create | `fable-mcp/.env.example` | Env var template |
|
|
| Create | `fable-mcp/fable_mcp/__init__.py` | Package marker |
|
|
| Create | `fable-mcp/fable_mcp/client.py` | `FableClient` async httpx wrapper + `FableAPIError` |
|
|
| Create | `fable-mcp/fable_mcp/server.py` | `FastMCP` instance + entry point `main()` |
|
|
| Create | `fable-mcp/fable_mcp/tools/__init__.py` | Package marker |
|
|
| Create | `fable-mcp/fable_mcp/tools/notes.py` | Notes CRUD tools |
|
|
| Create | `fable-mcp/fable_mcp/tools/tasks.py` | Tasks CRUD + status + log tools |
|
|
| Create | `fable-mcp/fable_mcp/tools/projects.py` | Projects CRUD tools |
|
|
| Create | `fable-mcp/fable_mcp/tools/milestones.py` | Milestones CRUD tools |
|
|
| Create | `fable-mcp/fable_mcp/tools/search.py` | Semantic search tool |
|
|
| Create | `fable-mcp/fable_mcp/tools/chat.py` | Chat send_message tool (SSE consumer) |
|
|
| Create | `fable-mcp/tests/conftest.py` | pytest fixtures (mock FableClient) |
|
|
| Create | `fable-mcp/tests/test_client.py` | FableClient unit tests |
|
|
| Create | `fable-mcp/tests/test_tools_notes.py` | Notes tool unit tests |
|
|
| Create | `fable-mcp/tests/test_tools_chat.py` | Chat tool unit tests (SSE parsing) |
|
|
|
|
---
|
|
|
|
### Task 8: Package scaffold
|
|
|
|
**Files:**
|
|
- Create: `fable-mcp/pyproject.toml`
|
|
- Create: `fable-mcp/.env.example`
|
|
- Create: `fable-mcp/fable_mcp/__init__.py`
|
|
- Create: `fable-mcp/fable_mcp/tools/__init__.py`
|
|
- Create: `fable-mcp/tests/__init__.py`
|
|
- Create: `fable-mcp/tests/conftest.py`
|
|
|
|
- [ ] **Step 1: Create pyproject.toml**
|
|
|
|
Create `fable-mcp/pyproject.toml`:
|
|
|
|
```toml
|
|
[build-system]
|
|
requires = ["hatchling"]
|
|
build-backend = "hatchling.build"
|
|
|
|
[project]
|
|
name = "fable-mcp"
|
|
version = "0.1.0"
|
|
description = "MCP server for Fabled Assistant"
|
|
requires-python = ">=3.12"
|
|
dependencies = [
|
|
"mcp[cli]>=1.0",
|
|
"httpx>=0.27",
|
|
"python-dotenv>=1.0",
|
|
]
|
|
|
|
[project.scripts]
|
|
fable-mcp = "fable_mcp.server:main"
|
|
|
|
[project.optional-dependencies]
|
|
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.21"]
|
|
```
|
|
|
|
- [ ] **Step 2: Create .env.example**
|
|
|
|
Create `fable-mcp/.env.example`:
|
|
|
|
```
|
|
FABLE_URL=http://localhost:8080
|
|
FABLE_API_KEY=fmcp_your_key_here
|
|
```
|
|
|
|
- [ ] **Step 3: Create package markers**
|
|
|
|
```bash
|
|
mkdir -p fable-mcp/fable_mcp/tools fable-mcp/tests
|
|
touch fable-mcp/fable_mcp/__init__.py
|
|
touch fable-mcp/fable_mcp/tools/__init__.py
|
|
touch fable-mcp/tests/__init__.py
|
|
```
|
|
|
|
- [ ] **Step 4: Create tests/conftest.py**
|
|
|
|
Create `fable-mcp/tests/conftest.py`:
|
|
|
|
```python
|
|
"""Shared pytest fixtures for fable-mcp tests."""
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_client():
|
|
"""A mock FableClient that returns empty responses by default."""
|
|
client = AsyncMock()
|
|
client.__aenter__ = AsyncMock(return_value=client)
|
|
client.__aexit__ = AsyncMock(return_value=False)
|
|
return client
|
|
```
|
|
|
|
- [ ] **Step 5: Install in dev mode**
|
|
|
|
```bash
|
|
cd fable-mcp && pip install -e ".[dev]"
|
|
```
|
|
|
|
- [ ] **Step 6: Commit scaffold**
|
|
|
|
```bash
|
|
git add fable-mcp/
|
|
git commit -m "feat: scaffold fable-mcp Python package"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: FableClient
|
|
|
|
**Files:**
|
|
- Create: `fable-mcp/fable_mcp/client.py`
|
|
- Create: `fable-mcp/tests/test_client.py`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `fable-mcp/tests/test_client.py`:
|
|
|
|
```python
|
|
"""Unit tests for FableClient configuration and error handling."""
|
|
import os
|
|
import pytest
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import httpx
|
|
import respx
|
|
|
|
from fable_mcp.client import FableClient, FableAPIError
|
|
|
|
|
|
def test_missing_env_vars_raises():
|
|
"""Client raises clearly if FABLE_URL or FABLE_API_KEY are missing."""
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
# Remove both vars
|
|
os.environ.pop("FABLE_URL", None)
|
|
os.environ.pop("FABLE_API_KEY", None)
|
|
with pytest.raises(ValueError, match="FABLE_URL"):
|
|
FableClient()
|
|
|
|
|
|
def test_auth_header_set():
|
|
"""Client sets Authorization header from FABLE_API_KEY."""
|
|
with patch.dict(os.environ, {"FABLE_URL": "http://localhost:8080", "FABLE_API_KEY": "fmcp_test"}):
|
|
client = FableClient()
|
|
assert client._headers["Authorization"] == "Bearer fmcp_test"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_get_raises_on_non_200():
|
|
"""FableAPIError is raised for non-2xx responses."""
|
|
respx.get("http://localhost:8080/api/notes").mock(
|
|
return_value=httpx.Response(403, json={"error": "forbidden"})
|
|
)
|
|
with patch.dict(os.environ, {"FABLE_URL": "http://localhost:8080", "FABLE_API_KEY": "fmcp_x"}):
|
|
client = FableClient()
|
|
async with client:
|
|
with pytest.raises(FableAPIError) as exc_info:
|
|
await client.get("/api/notes")
|
|
assert exc_info.value.status_code == 403
|
|
```
|
|
|
|
- [ ] **Step 2: Run to verify they fail**
|
|
|
|
```bash
|
|
cd fable-mcp && pytest tests/test_client.py -v
|
|
```
|
|
|
|
Expected: `ImportError` (module doesn't exist yet)
|
|
|
|
- [ ] **Step 3: Write the client**
|
|
|
|
Create `fable-mcp/fable_mcp/client.py`:
|
|
|
|
```python
|
|
"""Async HTTP client wrapping the Fable REST API."""
|
|
import os
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv()
|
|
|
|
|
|
class FableAPIError(Exception):
|
|
def __init__(self, status_code: int, message: str):
|
|
self.status_code = status_code
|
|
self.message = message
|
|
super().__init__(f"Fable API error {status_code}: {message}")
|
|
|
|
|
|
class FableClient:
|
|
"""Async httpx wrapper for the Fable API. Use as async context manager."""
|
|
|
|
def __init__(self):
|
|
url = os.environ.get("FABLE_URL", "").rstrip("/")
|
|
api_key = os.environ.get("FABLE_API_KEY", "")
|
|
if not url:
|
|
raise ValueError("FABLE_URL environment variable is required")
|
|
if not api_key:
|
|
raise ValueError("FABLE_API_KEY environment variable is required")
|
|
self._base_url = url
|
|
self._headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
self._http: httpx.AsyncClient | None = None
|
|
|
|
async def __aenter__(self):
|
|
self._http = httpx.AsyncClient(
|
|
base_url=self._base_url,
|
|
headers=self._headers,
|
|
timeout=30.0,
|
|
)
|
|
return self
|
|
|
|
async def __aexit__(self, *args):
|
|
if self._http:
|
|
await self._http.aclose()
|
|
|
|
def _check_response(self, response: httpx.Response) -> dict:
|
|
if response.status_code >= 400:
|
|
try:
|
|
detail = response.json().get("error", response.text)
|
|
except Exception:
|
|
detail = response.text
|
|
raise FableAPIError(response.status_code, detail)
|
|
if response.status_code == 204 or not response.content:
|
|
return {}
|
|
return response.json()
|
|
|
|
async def get(self, path: str, **params) -> dict:
|
|
resp = await self._http.get(path, params={k: v for k, v in params.items() if v is not None})
|
|
return self._check_response(resp)
|
|
|
|
async def post(self, path: str, body: dict | None = None) -> dict:
|
|
resp = await self._http.post(path, json=body or {})
|
|
return self._check_response(resp)
|
|
|
|
async def patch(self, path: str, body: dict) -> dict:
|
|
resp = await self._http.patch(path, json=body)
|
|
return self._check_response(resp)
|
|
|
|
async def put(self, path: str, body: dict) -> dict:
|
|
resp = await self._http.put(path, json=body)
|
|
return self._check_response(resp)
|
|
|
|
async def delete(self, path: str) -> dict:
|
|
resp = await self._http.delete(path)
|
|
return self._check_response(resp)
|
|
|
|
async def stream_get(self, path: str):
|
|
"""Yields raw SSE lines from a streaming GET endpoint."""
|
|
async with self._http.stream("GET", path) as response:
|
|
async for line in response.aiter_lines():
|
|
if line.startswith("data: "):
|
|
yield line[len("data: "):]
|
|
|
|
|
|
# Module-level singleton — initialized in server.py startup
|
|
_client: FableClient | None = None
|
|
|
|
|
|
def get_client() -> FableClient:
|
|
"""Return the module-level singleton. Must be initialized before use."""
|
|
if _client is None:
|
|
raise RuntimeError("FableClient not initialized. Call init_client() first.")
|
|
return _client
|
|
|
|
|
|
def init_client() -> FableClient:
|
|
"""Initialize the module-level singleton from environment variables."""
|
|
global _client
|
|
_client = FableClient()
|
|
return _client
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
```bash
|
|
cd fable-mcp && pytest tests/test_client.py -v
|
|
```
|
|
|
|
Expected: all tests pass
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add fable-mcp/fable_mcp/client.py fable-mcp/tests/test_client.py
|
|
git commit -m "feat: add FableClient async httpx wrapper with error handling"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Notes tools
|
|
|
|
**Files:**
|
|
- Create: `fable-mcp/fable_mcp/tools/notes.py`
|
|
- Create: `fable-mcp/tests/test_tools_notes.py`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `fable-mcp/tests/test_tools_notes.py`:
|
|
|
|
```python
|
|
"""Unit tests for notes tools — mock FableClient."""
|
|
import pytest
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_notes_passes_params():
|
|
"""list_notes passes query params to client.get."""
|
|
mock_get = AsyncMock(return_value={"notes": [], "total": 0})
|
|
with patch("fable_mcp.tools.notes.get_client") as mock_gc:
|
|
mock_gc.return_value.get = mock_get
|
|
from fable_mcp.tools.notes import list_notes
|
|
result = await list_notes(query="auth", limit=5)
|
|
mock_get.assert_called_once()
|
|
call_kwargs = mock_get.call_args
|
|
assert "auth" in str(call_kwargs)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_notes_returns_error_string_on_api_error():
|
|
"""list_notes returns error string when FableAPIError is raised."""
|
|
from fable_mcp.client import FableAPIError
|
|
with patch("fable_mcp.tools.notes.get_client") as mock_gc:
|
|
mock_gc.return_value.get = AsyncMock(side_effect=FableAPIError(404, "Not found"))
|
|
from fable_mcp.tools.notes import list_notes
|
|
result = await list_notes()
|
|
assert "404" in result
|
|
assert isinstance(result, str)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_note_sends_body():
|
|
"""create_note posts correct body to /api/notes."""
|
|
mock_post = AsyncMock(return_value={"id": 1, "title": "Test"})
|
|
with patch("fable_mcp.tools.notes.get_client") as mock_gc:
|
|
mock_gc.return_value.post = mock_post
|
|
from fable_mcp.tools.notes import create_note
|
|
await create_note(title="Test", body="Hello")
|
|
mock_post.assert_called_once()
|
|
body = mock_post.call_args[0][1]
|
|
assert body["title"] == "Test"
|
|
assert body["body"] == "Hello"
|
|
```
|
|
|
|
- [ ] **Step 2: Run to verify failure**
|
|
|
|
```bash
|
|
cd fable-mcp && pytest tests/test_tools_notes.py -v
|
|
```
|
|
|
|
Expected: `ImportError`
|
|
|
|
- [ ] **Step 3: Write the notes tools**
|
|
|
|
Create `fable-mcp/fable_mcp/tools/notes.py`:
|
|
|
|
```python
|
|
"""MCP tools for Fable notes."""
|
|
from typing import Any
|
|
|
|
from fable_mcp.client import FableAPIError, get_client
|
|
|
|
|
|
async def list_notes(
|
|
query: str | None = None,
|
|
tags: list[str] | None = None,
|
|
project_id: int | None = None,
|
|
limit: int = 20,
|
|
offset: int = 0,
|
|
) -> Any:
|
|
"""List notes. Optionally filter by text query, tags, or project."""
|
|
try:
|
|
params: dict = {"limit": limit, "offset": offset, "is_task": "false"}
|
|
if query:
|
|
params["q"] = query
|
|
if tags:
|
|
params["tag"] = tags
|
|
if project_id:
|
|
params["project_id"] = project_id
|
|
return await get_client().get("/api/notes", **params)
|
|
except FableAPIError as e:
|
|
return str(e)
|
|
except Exception as e:
|
|
return f"Error: {e}"
|
|
|
|
|
|
async def get_note(note_id: int) -> Any:
|
|
"""Get a single note by ID, including its full body."""
|
|
try:
|
|
return await get_client().get(f"/api/notes/{note_id}")
|
|
except FableAPIError as e:
|
|
return str(e)
|
|
except Exception as e:
|
|
return f"Error: {e}"
|
|
|
|
|
|
async def create_note(
|
|
title: str,
|
|
body: str = "",
|
|
tags: list[str] | None = None,
|
|
project_id: int | None = None,
|
|
) -> Any:
|
|
"""Create a new note."""
|
|
try:
|
|
payload: dict = {"title": title, "body": body}
|
|
if tags:
|
|
payload["tags"] = tags
|
|
if project_id:
|
|
payload["project_id"] = project_id
|
|
return await get_client().post("/api/notes", payload)
|
|
except FableAPIError as e:
|
|
return str(e)
|
|
except Exception as e:
|
|
return f"Error: {e}"
|
|
|
|
|
|
async def update_note(
|
|
note_id: int,
|
|
title: str | None = None,
|
|
body: str | None = None,
|
|
tags: list[str] | None = None,
|
|
project_id: int | None = None,
|
|
) -> Any:
|
|
"""Update an existing note. Only provided fields are changed."""
|
|
try:
|
|
payload: dict = {}
|
|
if title is not None:
|
|
payload["title"] = title
|
|
if body is not None:
|
|
payload["body"] = body
|
|
if tags is not None:
|
|
payload["tags"] = tags
|
|
if project_id is not None:
|
|
payload["project_id"] = project_id
|
|
return await get_client().patch(f"/api/notes/{note_id}", payload)
|
|
except FableAPIError as e:
|
|
return str(e)
|
|
except Exception as e:
|
|
return f"Error: {e}"
|
|
|
|
|
|
async def delete_note(note_id: int) -> Any:
|
|
"""Delete a note by ID."""
|
|
try:
|
|
await get_client().delete(f"/api/notes/{note_id}")
|
|
return {"status": "deleted", "id": note_id}
|
|
except FableAPIError as e:
|
|
return str(e)
|
|
except Exception as e:
|
|
return f"Error: {e}"
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify pass**
|
|
|
|
```bash
|
|
cd fable-mcp && pytest tests/test_tools_notes.py -v
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add fable-mcp/fable_mcp/tools/notes.py fable-mcp/tests/test_tools_notes.py
|
|
git commit -m "feat: add notes MCP tools (list/get/create/update/delete)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Tasks tools
|
|
|
|
**Files:**
|
|
- Create: `fable-mcp/fable_mcp/tools/tasks.py`
|
|
|
|
Tasks follow the exact same pattern as notes.py but target `/api/tasks` and include `patch_task_status` and `add_task_log`. Write the module following the same error-handling wrapper pattern as `notes.py`.
|
|
|
|
- [ ] **Step 1: Write failing tests first**
|
|
|
|
Create `fable-mcp/tests/test_tools_tasks.py`:
|
|
|
|
```python
|
|
"""Unit tests for tasks tools."""
|
|
import pytest
|
|
from unittest.mock import AsyncMock, patch
|
|
from fable_mcp.client import FableAPIError
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_tasks_passes_params():
|
|
mock_get = AsyncMock(return_value={"notes": [], "total": 0})
|
|
with patch("fable_mcp.tools.tasks.get_client") as mock_gc:
|
|
mock_gc.return_value.get = mock_get
|
|
from fable_mcp.tools.tasks import list_tasks
|
|
await list_tasks(project_id=1, status="open")
|
|
mock_get.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_tasks_returns_error_on_api_error():
|
|
with patch("fable_mcp.tools.tasks.get_client") as mock_gc:
|
|
mock_gc.return_value.get = AsyncMock(side_effect=FableAPIError(500, "server error"))
|
|
from fable_mcp.tools.tasks import list_tasks
|
|
result = await list_tasks()
|
|
assert "500" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_patch_task_status_posts_to_correct_path():
|
|
mock_patch = AsyncMock(return_value={"id": 1, "status": "done"})
|
|
with patch("fable_mcp.tools.tasks.get_client") as mock_gc:
|
|
mock_gc.return_value.patch = mock_patch
|
|
from fable_mcp.tools.tasks import patch_task_status
|
|
await patch_task_status(task_id=5, status="done")
|
|
call_args = mock_patch.call_args[0]
|
|
assert "/api/tasks/5/status" in call_args[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_task_log_posts_to_correct_path():
|
|
mock_post = AsyncMock(return_value={"id": 1})
|
|
with patch("fable_mcp.tools.tasks.get_client") as mock_gc:
|
|
mock_gc.return_value.post = mock_post
|
|
from fable_mcp.tools.tasks import add_task_log
|
|
await add_task_log(task_id=3, content="Did the thing")
|
|
call_args = mock_post.call_args[0]
|
|
assert "/api/tasks/3/logs" in call_args[0]
|
|
```
|
|
|
|
Run to verify failure:
|
|
```bash
|
|
cd fable-mcp && pytest tests/test_tools_tasks.py -v
|
|
```
|
|
Expected: `ImportError`
|
|
|
|
- [ ] **Step 2: Write the module**
|
|
|
|
Create `fable-mcp/fable_mcp/tools/tasks.py` with these functions (follow the `notes.py` pattern exactly — try/except FableAPIError returning str):
|
|
|
|
- `list_tasks(query, project_id, milestone_id, status, priority, limit, offset)` → `GET /api/tasks` with matching query params
|
|
- `get_task(task_id)` → `GET /api/tasks/{task_id}`
|
|
- `create_task(title, body, project_id, milestone_id, priority, status)` → `POST /api/tasks`
|
|
- `update_task(task_id, title, body, project_id, milestone_id, priority, status)` → `PUT /api/tasks/{task_id}` (Fable tasks use PUT not PATCH)
|
|
- `delete_task(task_id)` → `DELETE /api/tasks/{task_id}`
|
|
- `patch_task_status(task_id, status)` → `PATCH /api/tasks/{task_id}/status` with `{"status": status}`
|
|
- `add_task_log(task_id, content)` → `POST /api/tasks/{task_id}/logs` with `{"content": content}` (task_id goes in the URL path, confirmed in `routes/task_logs.py` which mounts under `/api/tasks`)
|
|
|
|
- [ ] **Step 3: Run tests to verify pass**
|
|
|
|
```bash
|
|
cd fable-mcp && pytest tests/test_tools_tasks.py tests/ -v
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add fable-mcp/fable_mcp/tools/tasks.py fable-mcp/tests/test_tools_tasks.py
|
|
git commit -m "feat: add tasks MCP tools (list/get/create/update/delete/status/log)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Projects tools
|
|
|
|
**Files:**
|
|
- Create: `fable-mcp/fable_mcp/tools/projects.py`
|
|
|
|
- [ ] **Step 1: Write failing tests first**
|
|
|
|
Create `fable-mcp/tests/test_tools_projects.py` following the same pattern as `test_tools_notes.py` — at minimum:
|
|
- `test_list_projects_calls_get` — mock `get_client().get`, assert it calls `/api/projects`
|
|
- `test_list_projects_returns_error_on_api_error` — assert error string returned on `FableAPIError`
|
|
|
|
Run to verify failure: `cd fable-mcp && pytest tests/test_tools_projects.py -v`
|
|
|
|
- [ ] **Step 2: Write the module**
|
|
|
|
Create `fable-mcp/fable_mcp/tools/projects.py` with:
|
|
|
|
- `list_projects()` → `GET /api/projects`
|
|
- `get_project(project_id)` → `GET /api/projects/{project_id}` (includes milestone summary)
|
|
- `create_project(title, description, goal, color)` → `POST /api/projects`
|
|
- `update_project(project_id, title, description, goal, status, color)` → `PATCH /api/projects/{project_id}`
|
|
- `delete_project(project_id)` → `DELETE /api/projects/{project_id}`
|
|
|
|
- [ ] **Step 3: Run tests to verify pass**
|
|
|
|
```bash
|
|
cd fable-mcp && pytest tests/test_tools_projects.py tests/ -v
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add fable-mcp/fable_mcp/tools/projects.py fable-mcp/tests/test_tools_projects.py
|
|
git commit -m "feat: add projects MCP tools"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 13: Milestones tools
|
|
|
|
**Files:**
|
|
- Create: `fable-mcp/fable_mcp/tools/milestones.py`
|
|
|
|
- [ ] **Step 1: Write failing tests first**
|
|
|
|
Create `fable-mcp/tests/test_tools_milestones.py` following the same pattern as `test_tools_notes.py` — at minimum:
|
|
- `test_list_milestones_calls_correct_path` — mock `get_client().get`, assert path contains `/api/projects/1/milestones`
|
|
- `test_list_milestones_returns_error_on_api_error` — assert error string returned
|
|
|
|
Run to verify failure: `cd fable-mcp && pytest tests/test_tools_milestones.py -v`
|
|
|
|
- [ ] **Step 2: Write the module**
|
|
|
|
Create `fable-mcp/fable_mcp/tools/milestones.py` with:
|
|
|
|
- `list_milestones(project_id, status)` → `GET /api/projects/{project_id}/milestones`
|
|
- `get_milestone(project_id, milestone_id)` → `GET /api/projects/{project_id}/milestones/{milestone_id}`
|
|
- `create_milestone(project_id, title, description, order_index)` → `POST /api/projects/{project_id}/milestones`
|
|
- `update_milestone(project_id, milestone_id, title, description, status, order_index)` → `PATCH /api/projects/{project_id}/milestones/{milestone_id}`
|
|
- `delete_milestone(project_id, milestone_id)` → `DELETE /api/projects/{project_id}/milestones/{milestone_id}`
|
|
|
|
- [ ] **Step 3: Run tests to verify pass**
|
|
|
|
```bash
|
|
cd fable-mcp && pytest tests/test_tools_milestones.py tests/ -v
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add fable-mcp/fable_mcp/tools/milestones.py fable-mcp/tests/test_tools_milestones.py
|
|
git commit -m "feat: add milestones MCP tools"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 14: Search tool
|
|
|
|
**Files:**
|
|
- Create: `fable-mcp/fable_mcp/tools/search.py`
|
|
|
|
- [ ] **Step 1: Write the module**
|
|
|
|
Create `fable-mcp/fable_mcp/tools/search.py`:
|
|
|
|
```python
|
|
"""MCP tool for semantic search across Fable notes and tasks."""
|
|
from typing import Any
|
|
|
|
from fable_mcp.client import FableAPIError, get_client
|
|
|
|
|
|
async def semantic_search(
|
|
query: str,
|
|
content_type: str = "all",
|
|
limit: int = 10,
|
|
) -> Any:
|
|
"""
|
|
Search notes and/or tasks by meaning using semantic similarity.
|
|
|
|
content_type: "note", "task", or "all" (default).
|
|
Returns ranked results with similarity scores.
|
|
"""
|
|
try:
|
|
return await get_client().get(
|
|
"/api/search",
|
|
q=query,
|
|
content_type=content_type,
|
|
limit=limit,
|
|
)
|
|
except FableAPIError as e:
|
|
return str(e)
|
|
except Exception as e:
|
|
return f"Error: {e}"
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add fable-mcp/fable_mcp/tools/search.py
|
|
git commit -m "feat: add semantic_search MCP tool"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 15: Chat tool (SSE consumer)
|
|
|
|
**Files:**
|
|
- Create: `fable-mcp/fable_mcp/tools/chat.py`
|
|
- Create: `fable-mcp/tests/test_tools_chat.py`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `fable-mcp/tests/test_tools_chat.py`:
|
|
|
|
```python
|
|
"""Unit tests for chat tool — SSE parsing logic."""
|
|
import json
|
|
import pytest
|
|
from unittest.mock import AsyncMock, patch, MagicMock
|
|
|
|
|
|
def test_parse_sse_done_event():
|
|
"""_parse_sse_events extracts response and tools_used from done event."""
|
|
from fable_mcp.tools.chat import _parse_done_event
|
|
raw = json.dumps({"type": "done", "content": "Hello world", "tools_used": ["create_task"]})
|
|
result = _parse_done_event(raw)
|
|
assert result["response"] == "Hello world"
|
|
assert result["tools_used"] == ["create_task"]
|
|
|
|
|
|
def test_parse_sse_non_done_returns_none():
|
|
"""_parse_done_event returns None for non-done events."""
|
|
from fable_mcp.tools.chat import _parse_done_event
|
|
raw = json.dumps({"type": "token", "content": "partial"})
|
|
assert _parse_done_event(raw) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_message_creates_conversation_if_none():
|
|
"""send_message creates a new conversation when none exists."""
|
|
mock_post = AsyncMock(side_effect=[
|
|
{"id": 42, "title": "Test", "conversation_type": "mcp"}, # create conv
|
|
{"id": 1}, # post message
|
|
])
|
|
mock_list = AsyncMock(return_value={"conversations": []})
|
|
|
|
async def mock_stream(path):
|
|
yield json.dumps({"type": "done", "content": "OK", "tools_used": []})
|
|
|
|
with patch("fable_mcp.tools.chat.get_client") as mock_gc:
|
|
client = MagicMock()
|
|
client.get = mock_list
|
|
client.post = mock_post
|
|
client.stream_get = mock_stream
|
|
mock_gc.return_value = client
|
|
|
|
from fable_mcp.tools.chat import send_message
|
|
result = await send_message("Hello", conversation_name="Test")
|
|
|
|
assert result["response"] == "OK"
|
|
assert result["conversation_id"] == 42
|
|
```
|
|
|
|
- [ ] **Step 2: Run to verify failure**
|
|
|
|
```bash
|
|
cd fable-mcp && pytest tests/test_tools_chat.py -v
|
|
```
|
|
|
|
- [ ] **Step 3: Write the chat tool**
|
|
|
|
Create `fable-mcp/fable_mcp/tools/chat.py`:
|
|
|
|
```python
|
|
"""MCP tool for sending messages to the Fable chat pipeline."""
|
|
import json
|
|
from typing import Any
|
|
|
|
from fable_mcp.client import FableAPIError, get_client
|
|
|
|
|
|
def _parse_done_event(raw_json: str) -> dict | None:
|
|
"""Parse a raw SSE data line. Returns result dict if type=='done', else None."""
|
|
try:
|
|
event = json.loads(raw_json)
|
|
if event.get("type") == "done":
|
|
return {
|
|
"response": event.get("content", ""),
|
|
"tools_used": event.get("tools_used", []),
|
|
}
|
|
return None
|
|
except (json.JSONDecodeError, AttributeError):
|
|
return None
|
|
|
|
|
|
async def _find_or_create_conversation(name: str) -> int:
|
|
"""Return conversation ID for the named MCP conversation, creating if needed."""
|
|
client = get_client()
|
|
# Search existing MCP conversations for matching title
|
|
data = await client.get("/api/chat/conversations", type="mcp", limit=100)
|
|
for conv in data.get("conversations", []):
|
|
if conv.get("title") == name:
|
|
return conv["id"]
|
|
# Not found — create a new one
|
|
conv = await client.post("/api/chat/conversations", {
|
|
"title": name,
|
|
"conversation_type": "mcp",
|
|
})
|
|
return conv["id"]
|
|
|
|
|
|
async def send_message(
|
|
message: str,
|
|
conversation_name: str | None = None,
|
|
) -> Any:
|
|
"""
|
|
Send a message to Fable's LLM pipeline and return the response.
|
|
|
|
Creates or continues a persistent MCP conversation in Fable.
|
|
The conversation is visible in Fable for audit purposes but excluded
|
|
from the normal chat list. Returns the assistant response and a list
|
|
of any tools the Fable LLM fired during generation.
|
|
"""
|
|
name = conversation_name or "MCP Session"
|
|
try:
|
|
client = get_client()
|
|
conv_id = await _find_or_create_conversation(name)
|
|
|
|
# Post the user message (this triggers generation)
|
|
await client.post(
|
|
f"/api/chat/conversations/{conv_id}/messages",
|
|
{"content": message, "role": "user"},
|
|
)
|
|
|
|
# Consume SSE stream until done event
|
|
result = None
|
|
async for line in client.stream_get(f"/api/chat/conversations/{conv_id}/stream"):
|
|
if not line.strip():
|
|
continue
|
|
parsed = _parse_done_event(line)
|
|
if parsed is not None:
|
|
result = parsed
|
|
break
|
|
|
|
if result is None:
|
|
return {"error": "Stream ended without a done event", "conversation_id": conv_id}
|
|
|
|
return {
|
|
"response": result["response"],
|
|
"tools_used": result["tools_used"],
|
|
"conversation_id": conv_id,
|
|
}
|
|
except FableAPIError as e:
|
|
return str(e)
|
|
except Exception as e:
|
|
return f"Error: {e}"
|
|
```
|
|
|
|
Note: verify the Fable message POST endpoint path by checking `src/scribe/routes/chat.py` — search for the route that accepts a user message and triggers generation. Adjust `/api/chat/conversations/{conv_id}/messages` if the actual path differs.
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
```bash
|
|
cd fable-mcp && pytest tests/test_tools_chat.py -v
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add fable-mcp/fable_mcp/tools/chat.py fable-mcp/tests/test_tools_chat.py
|
|
git commit -m "feat: add chat send_message MCP tool with SSE consumer"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 16: server.py + MCP registration
|
|
|
|
**Files:**
|
|
- Create: `fable-mcp/fable_mcp/server.py`
|
|
|
|
- [ ] **Step 1: Write server.py**
|
|
|
|
Create `fable-mcp/fable_mcp/server.py`:
|
|
|
|
```python
|
|
"""Fable MCP Server entry point."""
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
from fable_mcp.client import init_client
|
|
from fable_mcp.tools import notes, tasks, projects, milestones, search, chat
|
|
|
|
mcp = FastMCP("Fable Assistant")
|
|
|
|
# --- Notes ---
|
|
mcp.tool()(notes.list_notes)
|
|
mcp.tool()(notes.get_note)
|
|
mcp.tool()(notes.create_note)
|
|
mcp.tool()(notes.update_note)
|
|
mcp.tool()(notes.delete_note)
|
|
|
|
# --- Tasks ---
|
|
mcp.tool()(tasks.list_tasks)
|
|
mcp.tool()(tasks.get_task)
|
|
mcp.tool()(tasks.create_task)
|
|
mcp.tool()(tasks.update_task)
|
|
mcp.tool()(tasks.delete_task)
|
|
mcp.tool()(tasks.patch_task_status)
|
|
mcp.tool()(tasks.add_task_log)
|
|
|
|
# --- Projects ---
|
|
mcp.tool()(projects.list_projects)
|
|
mcp.tool()(projects.get_project)
|
|
mcp.tool()(projects.create_project)
|
|
mcp.tool()(projects.update_project)
|
|
mcp.tool()(projects.delete_project)
|
|
|
|
# --- Milestones ---
|
|
mcp.tool()(milestones.list_milestones)
|
|
mcp.tool()(milestones.get_milestone)
|
|
mcp.tool()(milestones.create_milestone)
|
|
mcp.tool()(milestones.update_milestone)
|
|
mcp.tool()(milestones.delete_milestone)
|
|
|
|
# --- Search ---
|
|
mcp.tool()(search.semantic_search)
|
|
|
|
# --- Chat ---
|
|
mcp.tool()(chat.send_message)
|
|
|
|
|
|
def main():
|
|
init_client()
|
|
mcp.run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
```
|
|
|
|
- [ ] **Step 2: Verify it imports cleanly**
|
|
|
|
```bash
|
|
cd fable-mcp && python -c "from fable_mcp.server import mcp; print('OK')"
|
|
```
|
|
|
|
Expected: `OK` (no import errors; FableClient init error is OK here since no env vars set)
|
|
|
|
- [ ] **Step 3: Run full test suite**
|
|
|
|
```bash
|
|
cd fable-mcp && pytest tests/ -v
|
|
```
|
|
|
|
Expected: all tests pass
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add fable-mcp/fable_mcp/server.py
|
|
git commit -m "feat: add server.py with all MCP tools registered"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 17: End-to-end test + Claude Code registration
|
|
|
|
- [ ] **Step 1: Install the package**
|
|
|
|
```bash
|
|
pip install -e ./fable-mcp
|
|
```
|
|
|
|
- [ ] **Step 2: Create a write-scoped API key**
|
|
|
|
In the Fable Settings UI → API Keys, create a key named "Claude MCP" with write scope. Copy the key.
|
|
|
|
- [ ] **Step 3: Test the server starts**
|
|
|
|
```bash
|
|
FABLE_URL=http://localhost:8080 FABLE_API_KEY=fmcp_<yourkey> fable-mcp
|
|
```
|
|
|
|
Expected: server starts and waits on stdio (no output, no crash). Ctrl+C to stop.
|
|
|
|
- [ ] **Step 4: Register with Claude Code**
|
|
|
|
Add to `~/.claude/settings.json` under `"mcpServers"`:
|
|
|
|
```json
|
|
"fable": {
|
|
"command": "fable-mcp",
|
|
"env": {
|
|
"FABLE_URL": "http://localhost:8080",
|
|
"FABLE_API_KEY": "fmcp_your_key_here"
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Verify tools appear in Claude Code**
|
|
|
|
In a new Claude Code session, run `/mcp` or check the tool list. You should see `list_notes`, `create_task`, `semantic_search`, `send_message`, etc.
|
|
|
|
- [ ] **Step 6: Smoke test each tool group**
|
|
|
|
Ask Claude: "Use Fable to list my projects." Verify a real response comes back.
|
|
|
|
- [ ] **Step 7: Final commit**
|
|
|
|
```bash
|
|
git add fable-mcp/
|
|
git commit -m "feat: fable-mcp v0.1.0 — complete MCP server with all tool groups"
|
|
```
|
|
|
|
---
|
|
|
|
## Future Tasks (post v0.1.0)
|
|
|
|
### Future Task A: In-app MCP install instructions (PyPI/Forgejo registry)
|
|
|
|
Once `fable-mcp` is published to a package registry (PyPI or the Forgejo package registry at `git.fabledsword.com`), add an install instructions panel to the API Keys tab in Settings. This replaces manual `pip install -e` with a one-liner and links the version shown to the package registry.
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/views/SettingsView.vue` — add "Install" section above the create form showing:
|
|
- `pip install fable-mcp` (or registry-specific command)
|
|
- Current published version badge (fetched from registry API)
|
|
- Link to full documentation/README
|
|
|
|
**Depends on:** fable-mcp extracted to its own Forgejo repo and published as a package.
|
|
|
|
---
|
|
|
|
### Future Task B: Forgejo MCP server
|
|
|
|
A second MCP server (`forgejo-mcp/`) that gives Claude direct access to the Forgejo instance at `git.fabledsword.com` for CI/CD automation. Capabilities: list/create/merge PRs, trigger CI runs, read build logs, manage releases, push config files.
|
|
|
|
**Scope:** Separate spec + plan session. Key questions to resolve in brainstorming:
|
|
- Authentication: Forgejo personal access token vs. OAuth app
|
|
- Which Gitea/Forgejo API endpoints to expose as tools
|
|
- Whether to use an existing `gitea-mcp` or build from scratch (check Forgejo's MCP compatibility)
|
|
- CI automation: trigger builds, read job logs, manage runner labels
|
|
|
|
**Prerequisite:** fable-mcp v0.1.0 working and registered in Claude Code.
|