Compare commits

...

22 Commits

Author SHA1 Message Date
bvandeusen e407043713 Merge pull request 'feat: API key auth, fable-mcp package, and semantic search endpoint' (#8) from dev into main
feat: API key auth, fable-mcp package, and semantic search endpoint
2026-03-24 03:05:16 +00:00
bvandeusen 00dee5ea0e ci: remove redundant pull_request trigger
All code goes through dev first where CI runs on push. PR runs
(dev→main) were duplicating already-validated checks and causing
queue contention. Build job was already gated to push-only events.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:53:32 -04:00
bvandeusen 55e260b392 fix(settings): fallback clipboard copy for http dev environments
navigator.clipboard.writeText() requires a secure context (HTTPS) and
silently fails on http://. Add an execCommand fallback so the API key
copy button works on non-secure dev instances.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:39:55 -04:00
bvandeusen 359cb24d9a feat(fable-mcp): implement FableClient, all tool modules, and server.py
- client.py: async httpx wrapper, FableAPIError, stream_get() SSE generator,
  singleton init_client()/get_client() and _reset_client() for tests
- tools/: notes, tasks, projects, milestones, search, chat — thin async
  functions that accept FableClient and call Fable REST endpoints
- server.py: FastMCP entry point with 20 tools registered via @mcp.tool(),
  each opening a fresh FableClient context per call; validates env vars at startup
- tests: 34 tests covering client HTTP behaviour, error handling, singleton,
  SSE streaming, and all tool modules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:51:32 -04:00
bvandeusen 6180ee65c3 feat: add .env and Claude config downloads to API key reveal
After creating a key, two download buttons appear:
- 'Download .env' — pre-filled FABLE_URL + FABLE_API_KEY
- 'Download Claude config' — ready-to-paste mcpServers JSON block

Also adds Future Task A (in-app install instructions) and Future Task B
(Forgejo MCP) to the implementation plan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:44:34 -04:00
bvandeusen 66571e16c1 feat: scaffold fable-mcp Python package
pyproject.toml with mcp[cli]/httpx/python-dotenv deps, entry point
fable-mcp=fable_mcp.server:main, dev deps for testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:21:55 -04:00
bvandeusen 73eb34d722 feat: add API Keys tab to Settings UI
Create/revoke API keys with read/write scope selector. One-time key
reveal with copy button. Keys table showing prefix, scope badge, last
used. Inline revoke confirmation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:15:24 -04:00
bvandeusen 47b4af281b feat: API key routes, search endpoint, conversation type wiring
- GET/POST/DELETE /api/api-keys blueprint registered
- GET /api/search?q=&content_type=&limit= semantic search endpoint
- create_conversation gains conversation_type param (default "chat")
- cleanup_old_conversations excludes mcp type from retention sweep
- POST /api/chat/conversations accepts conversation_type body field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:01:04 -04:00
bvandeusen e8a4ca915a feat: add bearer token auth to _check_auth, falls back to session
Checks Authorization: Bearer header first, hashes token, looks up in
api_keys. Read-only keys get 403 on non-GET. Admin routes inaccessible
to non-admin key owners. Session path unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:57:26 -04:00
bvandeusen 583a140485 feat: add ApiKey service with create/list/revoke/lookup
SHA-256 hashed keys, fmcp_ prefix, soft-delete revocation. Tests cover
pure logic (hash, prefix, format, uniqueness, scope validation) and
auth middleware bearer token path (pending auth.py update).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:56:51 -04:00
bvandeusen 7358909432 feat: add ApiKey model and migration 0027
Adds api_keys table with user_id FK, key_hash, key_prefix, scope
(read/write), last_used_at, revoked_at. SHA-256 hashed, soft-delete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:52:25 -04:00
bvandeusen c24b21f259 docs: add Fable MCP implementation plan
17-task TDD plan covering Phase 1 (API key auth, search endpoint,
conversation type wiring, Settings UI) and Phase 2 (fable-mcp Python
package with all tool modules, tests, and Claude Code registration).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:24:51 -04:00
bvandeusen e54d172487 docs: fix 8 spec review issues in Fable MCP design
- Add migration down_revision = "0026"
- Document create_conversation service + route changes for conversation_type
- Document SSE wire format (endpoints, event schema, termination event)
- Add retention sweep exclusion for mcp conversation type
- Rename type→content_type in search, document is_task mapping
- Clarify API keys cannot access admin-protected routes
- Document POST /api/chat/conversations accepting conversation_type body field
- Add delete_milestone to milestones tool list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:03:53 -04:00
bvandeusen f9973e22f1 docs: add Fable MCP design spec
Design for two sub-projects: API key auth support in fabledassistant
(bearer tokens, read/write scope, Settings UI) and a standalone Python
MCP server at fable-mcp/ exposing notes, tasks, projects, milestones,
semantic search, and chat tools to Claude Code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:00:26 -04:00
bvandeusen e9ddab7368 Merge pull request 'fix: remove registry build cache — Forgejo rejects large layer blobs' (#7) from dev into main
Reviewed-on: bvandeusen/FabledAssistant#7
2026-03-16 17:37:43 +00:00
Bryan Van Deusen 16b2b5c68e fix: remove registry build cache — Forgejo rejects large layer blobs
Even mode=min still hits the registry's blob size limit (400 Bad Request).
The local runner's Docker daemon layer cache is sufficient for fast
incremental builds without needing a separate registry cache tag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 12:53:02 -04:00
bvandeusen ce5b2ffaeb Merge pull request 'fix: downgrade buildx cache export from mode=max to mode=min' (#6) from dev into main
Reviewed-on: bvandeusen/FabledAssistant#6
2026-03-16 16:41:38 +00:00
Bryan Van Deusen c232a7d079 fix: downgrade buildx cache export from mode=max to mode=min
mode=max exports all intermediate layer blobs which exceeded the Forgejo
registry's blob upload size limit (400 Bad Request). mode=min only exports
the final image layers, keeping cache entries small enough for the registry.
The actual image build and push was succeeding; only the cache write failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 12:32:06 -04:00
bvandeusen 1c3f0ab7f7 Merge pull request 'Dev to Main: bug fixes' (#5) from dev into main
Reviewed-on: bvandeusen/FabledAssistant#5
2026-03-16 16:00:47 +00:00
bvandeusen 33f52081e5 docs: update summary.md for 2026-03-16 session
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 08:29:04 -04:00
bvandeusen a58dbe79f2 fix: fuzzy project resolution and guard against accidental project creation
- _resolve_project now uses reverse-substring + SequenceMatcher (≥0.55)
  so partial names like "Famous Supply" match "Famous Supply Co." reliably
- create_project runs similar-project checks before the confirmed gate so
  confirmed=true can't bypass them; threshold lowered to 0.55 to catch
  abbreviated names
- Error messages on project-not-found no longer suggest create_project,
  directing the model to list_projects first
- Tool description updated to explicitly prohibit calling create_project
  in response to a lookup failure
- tasks.py: fire-and-forget embedding update on create/update
- briefing_pipeline.py: await is_caldav_configured (was sync call)
- notes.py: remove erroneous duplicate count_query filter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 07:44:27 -04:00
bvandeusen 0d41b8be34 Fix briefing settings not loading when tab is active on mount
The watch(activeTab) only fires on changes, not the initial value.
When localStorage had settings_tab="briefing", onMounted skipped
loadBriefingTab() so the form always showed default empty values.
Follows the same pattern already used for the groups panel (line 338).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 23:05:10 -04:00
41 changed files with 4424 additions and 85 deletions
+3 -10
View File
@@ -2,7 +2,7 @@
#
# Push to dev: typecheck + lint + test → build :dev + :<sha>
# Tag v* (release): typecheck + lint + test → build :latest + :<sha> + :<version>
# Pull request: typecheck + lint + test only (no build)
# Pull request: no CI run — code is validated on dev push before any PR is opened
#
# To cut a release:
# Create a release via the Forgejo UI on main with a v* tag name.
@@ -27,13 +27,8 @@ on:
- "Dockerfile"
- "assets/**"
- ".forgejo/workflows/ci.yml"
pull_request:
paths:
- "src/**"
- "frontend/**"
- "tests/**"
- "pyproject.toml"
- ".forgejo/workflows/ci.yml"
# pull_request trigger intentionally omitted — all changes go through dev
# first, where CI already runs on push. PR runs would be redundant duplication.
env:
REGISTRY: git.fabledsword.com
@@ -142,5 +137,3 @@ jobs:
push: true
tags: ${{ steps.tags.outputs.value }}
build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }}
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max
+34
View File
@@ -0,0 +1,34 @@
"""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")
+271
View File
@@ -0,0 +1,271 @@
# Fable MCP — Design Spec
**Date:** 2026-03-23
**Status:** Approved
**Author:** bvandeusen + Claude
---
## Overview
A Python MCP (Model Context Protocol) server that lets Claude directly interface with a running Fabled Assistant instance. The goal is two-fold: Claude's stronger reasoning handles planning, documentation, and analysis while Fable remains the authoritative store for notes, tasks, projects, and milestones; and direct API access enables process improvement by letting Claude observe and operate on real data rather than working from descriptions.
The work is split into two sub-projects:
1. **Fable API Key Feature** — additions to the main `fabledassistant` project to support bearer token authentication
2. **Fable MCP Server** — a new standalone Python package at `fable-mcp/` in the same repo root
A third sub-project (Forgejo MCP for CI/CD automation) is planned as a follow-on after the Fable MCP is working.
---
## Sub-project 1: Fable API Key Feature
### Motivation
All existing Fable routes use session-based authentication (`session["user_id"]`). The MCP server runs as a local process and cannot maintain a browser session, so a stateless bearer token mechanism is required. API keys are user-scoped and carry a read/write permission level.
### Database
New table `api_keys` via migration `0027_add_api_keys.py`:
- `down_revision = "0026"` (the briefing tables migration)
- `revision = "0027"`
| Column | Type | Notes |
|--------|------|-------|
| `id` | Integer PK | |
| `user_id` | Integer FK → users | CASCADE delete |
| `name` | Text | Human-readable label |
| `key_hash` | Text | SHA-256 of the full key, used for lookup |
| `key_prefix` | Text | First 8 chars (e.g. `fmcp_ab12`), shown in UI |
| `scope` | Text | `"read"` or `"write"` |
| `last_used_at` | Timestamp | Nullable, updated on each authenticated request |
| `created_at` | Timestamp | |
| `revoked_at` | Timestamp | Nullable — soft delete |
Full keys are generated as `fmcp_<32 random url-safe chars>`, returned once at creation, never stored. Only the SHA-256 hash is persisted.
### Auth Middleware (`auth.py`)
`_check_auth` is updated to check the `Authorization: Bearer <token>` header before falling back to session auth:
1. If header present: hash the token, look up in `api_keys` where `revoked_at IS NULL`
2. If found: update `last_used_at`, set `g.user` and `g.api_key`
3. Scope enforcement: if `g.api_key.scope == "read"` and `request.method` is not `GET`, return 403
4. If header absent: existing session logic runs unchanged
Session auth is untouched — no regression risk for the web UI.
**Admin routes and API keys:** Routes decorated with `admin_required` check `user.role == "admin"` after auth resolves. API keys authenticate as the key owner — so a write-scoped key for a non-admin user will fail admin-protected routes with 403 (role check, not scope check). This is intentional: API keys cannot elevate privilege beyond the user's role.
### Routes (`/api/api-keys`)
New blueprint `api_keys_bp`, registered in `app.py`:
- `GET /api/api-keys` — list caller's keys (prefix, name, scope, last_used_at, created_at — never the hash or full key)
- `POST /api/api-keys` — create key; body: `{name, scope}`. Returns full key in response **once only**
- `DELETE /api/api-keys/:id` — revoke by setting `revoked_at = now()`
### Settings UI
New "API Keys" tab in `SettingsView.vue` (added to `VALID_TABS`):
- Create form: name input + read/write radio/toggle + "Generate Key" button
- After creation: one-time modal displaying the full key with a copy button and a warning that it will not be shown again
- Keys table: columns for name, scope badge, prefix, last used, revoke button
- Revoke shows inline confirmation before calling DELETE
### New Search Endpoint
`GET /api/search?q=<query>&content_type=note|task|all&limit=N`
Calls the existing `semantic_search_notes()` service (already in `services/embeddings.py`). The `content_type` parameter maps to the service's `is_task` argument:
- `content_type=note``is_task=False`
- `content_type=task``is_task=True`
- `content_type=all` (default) → `is_task=None`
Returns:
```json
{
"results": [
{"id": 1, "title": "...", "body": "...", "is_task": false, "similarity": 0.87, "tags": [...]}
],
"total": 5
}
```
This endpoint is needed by the MCP's `search.py` tool module. It reuses existing infrastructure with no new ML work.
### Conversation Type: `"mcp"`
A new conversation type `"mcp"` is added alongside `"chat"` and `"briefing"`. This requires changes in two places in the main app:
**`services/chat.py`** — `create_conversation(user_id, title, model)` gains an optional `conversation_type: str = "chat"` parameter, passed through to the `Conversation` constructor.
**`routes/chat.py`** — `POST /api/chat/conversations` accepts an optional `conversation_type` body field (defaults to `"chat"`), passed to `create_conversation`. `GET /api/chat/conversations` continues to default-filter to `conversation_type="chat"` (excluding `"mcp"` and `"briefing"`); pass `?type=mcp` to retrieve MCP conversations.
**Retention:** `cleanup_old_conversations` in `routes/chat.py` currently deletes conversations regardless of type. It must be updated to exclude `conversation_type="mcp"` from the sweep, so MCP audit-trail conversations are not automatically pruned.
MCP conversations:
- Are created with a caller-supplied name (e.g., `"MCP Session 2026-03-23"`) or auto-named
- Are excluded from the default chat list
- Are accessible via `GET /api/chat/conversations?type=mcp` for inspection
- Appear in the Fable UI if explicitly navigated to, providing an audit trail of MCP-driven interactions
### Chat SSE Wire Format
The MCP's `chat.py` tool must consume the existing SSE stream. The relevant endpoints and event schema:
- **Create conversation:** `POST /api/chat/conversations``{id, title, ...}`
- **Post message + start generation:** `POST /api/chat/conversations/:id/messages``{message_id, ...}`; then connect to:
- **Stream:** `GET /api/chat/conversations/:id/stream` (SSE)
SSE event format (each line is `data: <json>`):
- `{"type": "token", "content": "..."}` — streaming token
- `{"type": "tool_call", "name": "...", "result": {...}}` — tool fired
- `{"type": "done", "content": "...", "tools_used": [...]}` — generation complete; this is the termination event
The MCP tool reads tokens until it receives `type: "done"`, then returns `response` (full content) and `tools_used` (list of tool names).
---
## Sub-project 2: Fable MCP Server
### Location
`fable-mcp/` at the repository root, alongside `src/`, `frontend/`, `alembic/`. It is **not** part of the main Docker build and has no import relationship with `fabledassistant`. It will be extracted to its own Forgejo repo once stable.
### Package Structure
```
fable-mcp/
pyproject.toml # entry point: fable-mcp = "fable_mcp.server:main"
README.md
.env.example # FABLE_URL=http://localhost:8080, FABLE_API_KEY=fmcp_...
fable_mcp/
__init__.py
server.py # FastMCP instance, imports + registers all tool modules
client.py # FableClient: async httpx wrapper, env var config, FableAPIError
tools/
__init__.py
notes.py # list_notes, get_note, create_note, update_note, delete_note
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 `fabledassistant/fable-mcp/`
2. Once stable, extract to a new Forgejo repo (`bvandeusen/fable-mcp`)
3. Forgejo MCP (Gitea MCP) added as a second MCP server to automate build/push/config workflows — separate spec when ready
---
## Out of Scope (v1)
- MCP resources (browsable URI tree) — tools cover all use cases in Claude Code
- Forgejo MCP — follow-on spec
- Rate limiting on API key endpoints
- Key expiry / TTL
- Per-resource scope (e.g., key restricted to one project)
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
FABLE_URL=http://localhost:5000
FABLE_API_KEY=fmcp_your_key_here
View File
+124
View File
@@ -0,0 +1,124 @@
"""Async HTTP client for the Fable Assistant API."""
from __future__ import annotations
import os
from typing import Any, AsyncIterator
import httpx
class FableAPIError(Exception):
"""Raised when the Fable API returns a non-2xx response."""
def __init__(self, status_code: int, message: str) -> None:
self.status_code = status_code
super().__init__(f"Fable API error {status_code}: {message}")
class FableClient:
"""Async wrapper around httpx for the Fable REST API."""
def __init__(self) -> None:
url = os.environ.get("FABLE_URL", "").rstrip("/")
key = os.environ.get("FABLE_API_KEY", "")
if not url:
raise ValueError("FABLE_URL environment variable is required")
if not key:
raise ValueError("FABLE_API_KEY environment variable is required")
self.base_url = url
self._headers = {
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
}
self._client: httpx.AsyncClient | None = None
async def __aenter__(self) -> "FableClient":
self._client = httpx.AsyncClient(
base_url=self.base_url,
headers=self._headers,
timeout=30.0,
)
return self
async def __aexit__(self, *args: Any) -> None:
if self._client:
await self._client.aclose()
self._client = None
def _http(self) -> httpx.AsyncClient:
if self._client is None:
raise RuntimeError("FableClient must be used as an async context manager")
return self._client
@staticmethod
def _raise_for_status(response: httpx.Response) -> None:
if response.is_error:
try:
message = response.json().get("error", response.text)
except Exception:
message = response.text
raise FableAPIError(response.status_code, message)
async def get(self, path: str, **kwargs: Any) -> Any:
response = await self._http().get(path, **kwargs)
self._raise_for_status(response)
return response.json()
async def post(self, path: str, **kwargs: Any) -> Any:
response = await self._http().post(path, **kwargs)
self._raise_for_status(response)
return response.json()
async def patch(self, path: str, **kwargs: Any) -> Any:
response = await self._http().patch(path, **kwargs)
self._raise_for_status(response)
return response.json()
async def put(self, path: str, **kwargs: Any) -> Any:
response = await self._http().put(path, **kwargs)
self._raise_for_status(response)
return response.json()
async def delete(self, path: str, **kwargs: Any) -> Any:
response = await self._http().delete(path, **kwargs)
if response.status_code == 204:
return None
self._raise_for_status(response)
if response.content:
return response.json()
return None
async def stream_get(self, path: str, **kwargs: Any) -> AsyncIterator[str]:
"""Yield non-empty lines from a streaming GET response (SSE)."""
async with self._http().stream("GET", path, **kwargs) as response:
self._raise_for_status(response)
async for line in response.aiter_lines():
if line:
yield line
# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------
_client: FableClient | None = None
def init_client() -> FableClient:
"""Initialise the module-level FableClient singleton (reads env vars)."""
global _client
_client = FableClient()
return _client
def get_client() -> FableClient:
"""Return the singleton client; raises RuntimeError if not initialised."""
if _client is None:
raise RuntimeError("Call init_client() before get_client()")
return _client
def _reset_client() -> None:
"""Reset the singleton — used by tests only."""
global _client
_client = None
+356
View File
@@ -0,0 +1,356 @@
"""Fable MCP server — exposes Fable Assistant as MCP tools via stdio transport."""
from __future__ import annotations
from mcp.server.fastmcp import FastMCP
from dotenv import load_dotenv
from fable_mcp.client import FableClient
from fable_mcp.tools import notes, tasks, projects, milestones, search, chat
load_dotenv()
mcp = FastMCP("fable")
# ---------------------------------------------------------------------------
# Notes
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_notes(
limit: int = 20,
offset: int = 0,
tag: str = "",
search_text: str = "",
) -> dict:
"""List notes stored in Fable. Optionally filter by tag or search text."""
async with FableClient() as client:
return await notes.list_notes(
client,
limit=limit,
offset=offset,
tag=tag or None,
search=search_text or None,
)
@mcp.tool()
async def fable_get_note(note_id: int) -> dict:
"""Fetch the full content of a single Fable note by its ID."""
async with FableClient() as client:
return await notes.get_note(client, note_id=note_id)
@mcp.tool()
async def fable_create_note(
title: str,
body: str = "",
tags: list[str] | None = None,
project_id: int = 0,
) -> dict:
"""Create a new note in Fable."""
async with FableClient() as client:
return await notes.create_note(
client,
title=title,
body=body,
tags=tags,
project_id=project_id or None,
)
@mcp.tool()
async def fable_update_note(
note_id: int,
title: str = "",
body: str = "",
tags: list[str] | None = None,
project_id: int = 0,
) -> dict:
"""Update an existing Fable note. Only provided fields are changed."""
async with FableClient() as client:
return await notes.update_note(
client,
note_id=note_id,
title=title or None,
body=body or None,
tags=tags,
project_id=project_id or None,
)
@mcp.tool()
async def fable_delete_note(note_id: int) -> str:
"""Delete a Fable note by ID."""
async with FableClient() as client:
await notes.delete_note(client, note_id=note_id)
return f"Note {note_id} deleted."
# ---------------------------------------------------------------------------
# Tasks
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_tasks(
limit: int = 20,
offset: int = 0,
status: str = "",
project_id: int = 0,
) -> dict:
"""List tasks in Fable. Filter by status (todo/in_progress/done) or project."""
async with FableClient() as client:
return await tasks.list_tasks(
client,
limit=limit,
offset=offset,
status=status or None,
project_id=project_id or None,
)
@mcp.tool()
async def fable_get_task(task_id: int) -> dict:
"""Fetch a single Fable task by ID, including parent task title."""
async with FableClient() as client:
return await tasks.get_task(client, task_id=task_id)
@mcp.tool()
async def fable_create_task(
title: str,
body: str = "",
status: str = "todo",
priority: str = "",
project_id: int = 0,
milestone_id: int = 0,
parent_id: int = 0,
tags: list[str] | None = None,
) -> dict:
"""Create a new task in Fable."""
async with FableClient() as client:
return await tasks.create_task(
client,
title=title,
body=body,
status=status,
priority=priority or None,
project_id=project_id or None,
milestone_id=milestone_id or None,
parent_id=parent_id or None,
tags=tags,
)
@mcp.tool()
async def fable_update_task(
task_id: int,
title: str = "",
body: str = "",
status: str = "",
priority: str = "",
project_id: int = 0,
milestone_id: int = 0,
) -> dict:
"""Update an existing Fable task. Only provided fields are changed."""
async with FableClient() as client:
return await tasks.update_task(
client,
task_id=task_id,
title=title or None,
body=body or None,
status=status or None,
priority=priority or None,
project_id=project_id or None,
milestone_id=milestone_id or None,
)
@mcp.tool()
async def fable_add_task_log(task_id: int, body: str) -> dict:
"""Append a progress log entry to a Fable task."""
async with FableClient() as client:
return await tasks.add_task_log(client, task_id=task_id, body=body)
# ---------------------------------------------------------------------------
# Projects
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_projects() -> dict:
"""List all Fable projects for the current user."""
async with FableClient() as client:
return await projects.list_projects(client)
@mcp.tool()
async def fable_get_project(project_id: int) -> dict:
"""Fetch a Fable project by ID, including milestone summary."""
async with FableClient() as client:
return await projects.get_project(client, project_id=project_id)
@mcp.tool()
async def fable_create_project(
title: str,
description: str = "",
goal: str = "",
status: str = "active",
color: str = "",
) -> dict:
"""Create a new project in Fable."""
async with FableClient() as client:
return await projects.create_project(
client,
title=title,
description=description,
goal=goal or None,
status=status,
color=color or None,
)
@mcp.tool()
async def fable_update_project(
project_id: int,
title: str = "",
description: str = "",
goal: str = "",
status: str = "",
color: str = "",
) -> dict:
"""Update an existing Fable project."""
async with FableClient() as client:
return await projects.update_project(
client,
project_id=project_id,
title=title or None,
description=description or None,
goal=goal or None,
status=status or None,
color=color or None,
)
# ---------------------------------------------------------------------------
# Milestones
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_milestones(project_id: int) -> dict:
"""List milestones for a Fable project."""
async with FableClient() as client:
return await milestones.list_milestones(client, project_id=project_id)
@mcp.tool()
async def fable_create_milestone(
project_id: int,
title: str,
description: str = "",
status: str = "active",
) -> dict:
"""Create a milestone within a Fable project."""
async with FableClient() as client:
return await milestones.create_milestone(
client,
project_id=project_id,
title=title,
description=description,
status=status,
)
@mcp.tool()
async def fable_update_milestone(
project_id: int,
milestone_id: int,
title: str = "",
description: str = "",
status: str = "",
order_index: int = -1,
) -> dict:
"""Update a Fable milestone."""
async with FableClient() as client:
return await milestones.update_milestone(
client,
project_id=project_id,
milestone_id=milestone_id,
title=title or None,
description=description or None,
status=status or None,
order_index=order_index if order_index >= 0 else None,
)
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_search(
q: str,
content_type: str = "all",
limit: int = 10,
) -> dict:
"""Semantic search over Fable notes and tasks.
content_type: "note", "task", or "all" (default).
Returns results ranked by similarity with id, title, body snippet, tags.
"""
async with FableClient() as client:
return await search.search(client, q=q, content_type=content_type, limit=limit)
# ---------------------------------------------------------------------------
# Chat
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_conversations(limit: int = 20, offset: int = 0) -> dict:
"""List MCP chat conversations stored in Fable."""
async with FableClient() as client:
return await chat.list_conversations(client, limit=limit, offset=offset)
@mcp.tool()
async def fable_send_message(
message: str,
conversation_id: str = "",
think: bool = False,
) -> dict:
"""Send a message to Fable's LLM and receive the full response.
Fable handles tool use, RAG, and history internally.
Pass conversation_id to continue an existing conversation.
Returns conversation_id, response text, and any tool_call events.
"""
async with FableClient() as client:
return await chat.send_message(
client,
message=message,
conversation_id=conversation_id or None,
think=think,
)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
# Validate env vars at startup — raises ValueError with a clear message if missing.
FableClient()
mcp.run(transport="stdio")
if __name__ == "__main__":
main()
+75
View File
@@ -0,0 +1,75 @@
"""MCP tools for Fable chat — create conversations and stream responses."""
from __future__ import annotations
import json
from typing import Any
from fable_mcp.client import FableClient
async def list_conversations(
client: FableClient,
*,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
"""List MCP chat conversations."""
params: dict[str, Any] = {"limit": limit, "offset": offset, "type": "mcp"}
return await client.get("/api/chat/conversations", params=params)
async def send_message(
client: FableClient,
*,
message: str,
conversation_id: str | None = None,
think: bool = False,
) -> dict[str, Any]:
"""Send a message to Fable and return the full assistant response.
Creates a new MCP conversation if conversation_id is None.
Streams the SSE response and accumulates token chunks into a single string.
Returns:
Dict with:
- "conversation_id": str
- "response": str (full assistant message)
- "tool_calls": list of any tool_call events observed
"""
if conversation_id is None:
conv = await client.post(
"/api/chat/conversations",
json={"conversation_type": "mcp"},
)
conversation_id = conv["id"]
# Build SSE stream URL with message as query param
params: dict[str, Any] = {"message": message}
if think:
params["think"] = "true"
tokens: list[str] = []
tool_calls: list[Any] = []
stream_path = f"/api/chat/conversations/{conversation_id}/stream"
async for raw_line in client.stream_get(stream_path, params=params):
if not raw_line.startswith("data: "):
continue
payload_str = raw_line[len("data: "):]
try:
event = json.loads(payload_str)
except json.JSONDecodeError:
continue
event_type = event.get("type")
if event_type == "token":
tokens.append(event.get("content", ""))
elif event_type == "tool_call":
tool_calls.append(event)
elif event_type == "done":
break
return {
"conversation_id": conversation_id,
"response": "".join(tokens),
"tool_calls": tool_calls,
}
+53
View File
@@ -0,0 +1,53 @@
"""MCP tools for Fable milestones."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_milestones(client: FableClient, *, project_id: int) -> dict[str, Any]:
"""List milestones for a project."""
return await client.get(f"/api/projects/{project_id}/milestones")
async def create_milestone(
client: FableClient,
*,
project_id: int,
title: str,
description: str = "",
status: str = "active",
) -> dict[str, Any]:
"""Create a milestone within a project."""
payload: dict[str, Any] = {
"title": title,
"description": description,
"status": status,
}
return await client.post(f"/api/projects/{project_id}/milestones", json=payload)
async def update_milestone(
client: FableClient,
*,
project_id: int,
milestone_id: int,
title: str | None = None,
description: str | None = None,
status: str | None = None,
order_index: int | None = None,
) -> dict[str, Any]:
"""Update an existing milestone."""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if status is not None:
payload["status"] = status
if order_index is not None:
payload["order_index"] = order_index
return await client.patch(
f"/api/projects/{project_id}/milestones/{milestone_id}", json=payload
)
+72
View File
@@ -0,0 +1,72 @@
"""MCP tools for Fable notes."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_notes(
client: FableClient,
*,
limit: int = 20,
offset: int = 0,
tag: str | None = None,
search: str | None = None,
) -> dict[str, Any]:
"""List notes with optional filtering."""
params: dict[str, Any] = {"limit": limit, "offset": offset}
if tag:
params["tag"] = tag
if search:
params["search"] = search
return await client.get("/api/notes", params=params)
async def get_note(client: FableClient, *, note_id: int) -> dict[str, Any]:
"""Fetch a single note by ID."""
return await client.get(f"/api/notes/{note_id}")
async def create_note(
client: FableClient,
*,
title: str,
body: str = "",
tags: list[str] | None = None,
project_id: int | None = None,
) -> dict[str, Any]:
"""Create a new note."""
payload: dict[str, Any] = {"title": title, "body": body}
if tags is not None:
payload["tags"] = tags
if project_id is not None:
payload["project_id"] = project_id
return await client.post("/api/notes", json=payload)
async def update_note(
client: FableClient,
*,
note_id: int,
title: str | None = None,
body: str | None = None,
tags: list[str] | None = None,
project_id: int | None = None,
) -> dict[str, Any]:
"""Update an existing note."""
payload: dict[str, Any] = {}
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 client.patch(f"/api/notes/{note_id}", json=payload)
async def delete_note(client: FableClient, *, note_id: int) -> None:
"""Delete a note by ID."""
await client.delete(f"/api/notes/{note_id}")
+59
View File
@@ -0,0 +1,59 @@
"""MCP tools for Fable projects."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_projects(client: FableClient) -> dict[str, Any]:
"""List all projects for the authenticated user."""
return await client.get("/api/projects")
async def get_project(client: FableClient, *, project_id: int) -> dict[str, Any]:
"""Fetch a project by ID (includes milestone summary)."""
return await client.get(f"/api/projects/{project_id}")
async def create_project(
client: FableClient,
*,
title: str,
description: str = "",
goal: str | None = None,
status: str = "active",
color: str | None = None,
) -> dict[str, Any]:
"""Create a new project."""
payload: dict[str, Any] = {"title": title, "description": description, "status": status}
if goal is not None:
payload["goal"] = goal
if color is not None:
payload["color"] = color
return await client.post("/api/projects", json=payload)
async def update_project(
client: FableClient,
*,
project_id: int,
title: str | None = None,
description: str | None = None,
goal: str | None = None,
status: str | None = None,
color: str | None = None,
) -> dict[str, Any]:
"""Update an existing project."""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if goal is not None:
payload["goal"] = goal
if status is not None:
payload["status"] = status
if color is not None:
payload["color"] = color
return await client.patch(f"/api/projects/{project_id}", json=payload)
+30
View File
@@ -0,0 +1,30 @@
"""MCP tool for semantic search across Fable notes and tasks."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def search(
client: FableClient,
*,
q: str,
content_type: str = "all",
limit: int = 10,
) -> dict[str, Any]:
"""Semantic search over notes and/or tasks.
Args:
q: Search query string.
content_type: One of "note", "task", or "all" (default).
limit: Maximum number of results to return.
Returns:
Dict with "results" list and "total" count.
Each result has: id, title, body, is_task, tags, similarity.
"""
params: dict[str, Any] = {"q": q, "limit": limit}
if content_type != "all":
params["content_type"] = content_type
return await client.get("/api/search", params=params)
+93
View File
@@ -0,0 +1,93 @@
"""MCP tools for Fable tasks."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_tasks(
client: FableClient,
*,
limit: int = 20,
offset: int = 0,
status: str | None = None,
project_id: int | None = None,
) -> dict[str, Any]:
"""List tasks with optional filtering."""
params: dict[str, Any] = {"limit": limit, "offset": offset}
if status:
params["status"] = status
if project_id is not None:
params["project_id"] = project_id
return await client.get("/api/tasks", params=params)
async def get_task(client: FableClient, *, task_id: int) -> dict[str, Any]:
"""Fetch a single task by ID (includes parent_title)."""
return await client.get(f"/api/tasks/{task_id}")
async def create_task(
client: FableClient,
*,
title: str,
body: str = "",
status: str = "todo",
priority: str | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
parent_id: int | None = None,
tags: list[str] | None = None,
) -> dict[str, Any]:
"""Create a new task."""
payload: dict[str, Any] = {"title": title, "body": body, "status": status}
if priority is not None:
payload["priority"] = priority
if project_id is not None:
payload["project_id"] = project_id
if milestone_id is not None:
payload["milestone_id"] = milestone_id
if parent_id is not None:
payload["parent_id"] = parent_id
if tags is not None:
payload["tags"] = tags
return await client.post("/api/tasks", json=payload)
async def update_task(
client: FableClient,
*,
task_id: int,
title: str | None = None,
body: str | None = None,
status: str | None = None,
priority: str | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
) -> dict[str, Any]:
"""Update an existing task."""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if body is not None:
payload["body"] = body
if status is not None:
payload["status"] = status
if priority is not None:
payload["priority"] = priority
if project_id is not None:
payload["project_id"] = project_id
if milestone_id is not None:
payload["milestone_id"] = milestone_id
return await client.patch(f"/api/tasks/{task_id}", json=payload)
async def add_task_log(
client: FableClient,
*,
task_id: int,
body: str,
) -> dict[str, Any]:
"""Append a log entry to a task."""
return await client.post(f"/api/tasks/{task_id}/logs", json={"body": body})
+24
View File
@@ -0,0 +1,24 @@
[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"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
View File
+12
View File
@@ -0,0 +1,12 @@
"""Shared pytest fixtures for fable-mcp tests."""
from unittest.mock import AsyncMock, MagicMock
import pytest
@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
+142
View File
@@ -0,0 +1,142 @@
"""Tests for FableClient."""
import os
import pytest
import respx
import httpx
from unittest.mock import patch
from fable_mcp.client import FableClient, FableAPIError, get_client, init_client, _reset_client
@pytest.fixture(autouse=True)
def reset_singleton():
"""Reset the module-level client singleton between tests."""
_reset_client()
yield
_reset_client()
@pytest.fixture
def base_env(monkeypatch):
monkeypatch.setenv("FABLE_URL", "http://fable.test")
monkeypatch.setenv("FABLE_API_KEY", "fmcp_testkey")
class TestFableClientInit:
def test_missing_fable_url_raises(self, monkeypatch):
monkeypatch.delenv("FABLE_URL", raising=False)
monkeypatch.setenv("FABLE_API_KEY", "fmcp_testkey")
with pytest.raises(ValueError, match="FABLE_URL"):
FableClient()
def test_missing_api_key_raises(self, monkeypatch):
monkeypatch.setenv("FABLE_URL", "http://fable.test")
monkeypatch.delenv("FABLE_API_KEY", raising=False)
with pytest.raises(ValueError, match="FABLE_API_KEY"):
FableClient()
def test_trailing_slash_stripped(self, base_env):
with patch.dict(os.environ, {"FABLE_URL": "http://fable.test/"}):
client = FableClient()
assert client.base_url == "http://fable.test"
def test_auth_header_set(self, base_env):
client = FableClient()
assert client._headers["Authorization"] == "Bearer fmcp_testkey"
class TestFableClientHTTP:
@respx.mock
@pytest.mark.asyncio
async def test_get_returns_json(self, base_env):
respx.get("http://fable.test/api/notes").mock(
return_value=httpx.Response(200, json={"notes": []})
)
async with FableClient() as client:
data = await client.get("/api/notes")
assert data == {"notes": []}
@respx.mock
@pytest.mark.asyncio
async def test_non_2xx_raises_fable_api_error(self, base_env):
respx.get("http://fable.test/api/notes").mock(
return_value=httpx.Response(401, json={"error": "Unauthorized"})
)
async with FableClient() as client:
with pytest.raises(FableAPIError) as exc_info:
await client.get("/api/notes")
assert exc_info.value.status_code == 401
@respx.mock
@pytest.mark.asyncio
async def test_post_sends_json(self, base_env):
respx.post("http://fable.test/api/notes").mock(
return_value=httpx.Response(201, json={"id": 1, "title": "Test"})
)
async with FableClient() as client:
data = await client.post("/api/notes", json={"title": "Test"})
assert data["id"] == 1
@respx.mock
@pytest.mark.asyncio
async def test_patch_sends_json(self, base_env):
respx.patch("http://fable.test/api/notes/1").mock(
return_value=httpx.Response(200, json={"id": 1, "title": "Updated"})
)
async with FableClient() as client:
data = await client.patch("/api/notes/1", json={"title": "Updated"})
assert data["title"] == "Updated"
@respx.mock
@pytest.mark.asyncio
async def test_delete_returns_none_on_204(self, base_env):
respx.delete("http://fable.test/api/notes/1").mock(
return_value=httpx.Response(204)
)
async with FableClient() as client:
result = await client.delete("/api/notes/1")
assert result is None
@respx.mock
@pytest.mark.asyncio
async def test_fable_api_error_includes_message(self, base_env):
respx.get("http://fable.test/api/notes").mock(
return_value=httpx.Response(404, json={"error": "Not found"})
)
async with FableClient() as client:
with pytest.raises(FableAPIError) as exc_info:
await client.get("/api/notes")
assert "Not found" in str(exc_info.value)
assert exc_info.value.status_code == 404
class TestSingleton:
def test_get_client_before_init_raises(self):
with pytest.raises(RuntimeError, match="init_client"):
get_client()
def test_init_client_returns_instance(self, base_env):
client = init_client()
assert isinstance(client, FableClient)
def test_get_client_after_init_returns_same(self, base_env):
init_client()
c1 = get_client()
c2 = get_client()
assert c1 is c2
class TestStreamGet:
@respx.mock
@pytest.mark.asyncio
async def test_stream_get_yields_lines(self, base_env):
sse_body = b"data: line1\n\ndata: line2\n\n"
respx.get("http://fable.test/api/stream").mock(
return_value=httpx.Response(200, content=sse_body)
)
lines = []
async with FableClient() as client:
async for line in client.stream_get("/api/stream"):
lines.append(line)
assert "data: line1" in lines
assert "data: line2" in lines
+58
View File
@@ -0,0 +1,58 @@
"""Tests for chat MCP tools."""
import json
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_send_message_creates_conversation_and_streams(client):
from fable_mcp.tools.chat import send_message
client.post = AsyncMock(return_value={"id": "conv-1"})
async def fake_stream(path, **kwargs):
for line in [
'data: {"type": "token", "content": "Hello"}',
'data: {"type": "token", "content": " world"}',
'data: {"type": "done"}',
]:
yield line
client.stream_get = fake_stream
result = await send_message(client, message="Hi", conversation_id=None)
assert result["conversation_id"] == "conv-1"
assert "Hello world" in result["response"]
@pytest.mark.asyncio
async def test_send_message_reuses_existing_conversation(client):
from fable_mcp.tools.chat import send_message
async def fake_stream(path, **kwargs):
for line in [
'data: {"type": "token", "content": "Reply"}',
'data: {"type": "done"}',
]:
yield line
client.stream_get = fake_stream
result = await send_message(client, message="Hi", conversation_id="existing-conv")
# Should not call post to create a conversation
client.post.assert_not_called()
assert result["conversation_id"] == "existing-conv"
@pytest.mark.asyncio
async def test_list_conversations_calls_get(client):
from fable_mcp.tools.chat import list_conversations
client.get = AsyncMock(return_value={"conversations": []})
await list_conversations(client)
client.get.assert_called_once()
assert "/api/chat/conversations" in client.get.call_args[0][0]
+35
View File
@@ -0,0 +1,35 @@
"""Tests for milestones MCP tools."""
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_list_milestones_calls_correct_endpoint(client):
from fable_mcp.tools.milestones import list_milestones
client.get = AsyncMock(return_value={"milestones": []})
await list_milestones(client, project_id=3)
client.get.assert_called_once_with("/api/projects/3/milestones")
@pytest.mark.asyncio
async def test_create_milestone_posts_to_project_endpoint(client):
from fable_mcp.tools.milestones import create_milestone
client.post = AsyncMock(return_value={"id": 10, "title": "M1"})
await create_milestone(client, project_id=3, title="M1")
path = client.post.call_args[0][0]
assert path == "/api/projects/3/milestones"
assert client.post.call_args[1]["json"]["title"] == "M1"
@pytest.mark.asyncio
async def test_update_milestone_patches(client):
from fable_mcp.tools.milestones import update_milestone
client.patch = AsyncMock(return_value={"id": 10, "status": "completed"})
await update_milestone(client, project_id=3, milestone_id=10, status="completed")
path = client.patch.call_args[0][0]
assert "/api/projects/3/milestones/10" in path
+56
View File
@@ -0,0 +1,56 @@
"""Tests for notes MCP tools."""
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_list_notes_calls_get(client):
from fable_mcp.tools.notes import list_notes
client.get = AsyncMock(return_value={"notes": [], "total": 0})
result = await list_notes(client, limit=10)
client.get.assert_called_once()
call_args = client.get.call_args
assert "/api/notes" in call_args[0][0]
@pytest.mark.asyncio
async def test_get_note_calls_correct_endpoint(client):
from fable_mcp.tools.notes import get_note
client.get = AsyncMock(return_value={"id": 42, "title": "Hello"})
result = await get_note(client, note_id=42)
client.get.assert_called_once_with("/api/notes/42")
assert result["id"] == 42
@pytest.mark.asyncio
async def test_create_note_posts_payload(client):
from fable_mcp.tools.notes import create_note
client.post = AsyncMock(return_value={"id": 1, "title": "My Note"})
result = await create_note(client, title="My Note", body="Content")
client.post.assert_called_once()
call_kwargs = client.post.call_args[1]
assert call_kwargs["json"]["title"] == "My Note"
assert call_kwargs["json"]["body"] == "Content"
@pytest.mark.asyncio
async def test_update_note_patches_payload(client):
from fable_mcp.tools.notes import update_note
client.patch = AsyncMock(return_value={"id": 1, "title": "Updated"})
result = await update_note(client, note_id=1, title="Updated")
client.patch.assert_called_once()
path = client.patch.call_args[0][0]
assert "/api/notes/1" in path
@pytest.mark.asyncio
async def test_delete_note_calls_delete(client):
from fable_mcp.tools.notes import delete_note
client.delete = AsyncMock(return_value=None)
await delete_note(client, note_id=5)
client.delete.assert_called_once_with("/api/notes/5")
+42
View File
@@ -0,0 +1,42 @@
"""Tests for projects MCP tools."""
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_list_projects_calls_get(client):
from fable_mcp.tools.projects import list_projects
client.get = AsyncMock(return_value={"projects": []})
await list_projects(client)
client.get.assert_called_once_with("/api/projects")
@pytest.mark.asyncio
async def test_get_project_calls_correct_endpoint(client):
from fable_mcp.tools.projects import get_project
client.get = AsyncMock(return_value={"id": 2, "title": "Proj"})
result = await get_project(client, project_id=2)
client.get.assert_called_once_with("/api/projects/2")
@pytest.mark.asyncio
async def test_create_project_posts_payload(client):
from fable_mcp.tools.projects import create_project
client.post = AsyncMock(return_value={"id": 4, "title": "New"})
await create_project(client, title="New", description="Desc")
call_kwargs = client.post.call_args[1]
assert call_kwargs["json"]["title"] == "New"
assert call_kwargs["json"]["description"] == "Desc"
@pytest.mark.asyncio
async def test_update_project_patches(client):
from fable_mcp.tools.projects import update_project
client.patch = AsyncMock(return_value={"id": 4, "status": "active"})
await update_project(client, project_id=4, status="active")
assert "/api/projects/4" in client.patch.call_args[0][0]
+54
View File
@@ -0,0 +1,54 @@
"""Tests for tasks MCP tools."""
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_list_tasks_calls_get(client):
from fable_mcp.tools.tasks import list_tasks
client.get = AsyncMock(return_value={"tasks": [], "total": 0})
await list_tasks(client)
client.get.assert_called_once()
assert "/api/tasks" in client.get.call_args[0][0]
@pytest.mark.asyncio
async def test_get_task_calls_correct_endpoint(client):
from fable_mcp.tools.tasks import get_task
client.get = AsyncMock(return_value={"id": 7, "title": "Fix bug"})
result = await get_task(client, task_id=7)
client.get.assert_called_once_with("/api/tasks/7")
@pytest.mark.asyncio
async def test_create_task_posts_payload(client):
from fable_mcp.tools.tasks import create_task
client.post = AsyncMock(return_value={"id": 3, "title": "New task"})
await create_task(client, title="New task")
call_kwargs = client.post.call_args[1]
assert call_kwargs["json"]["title"] == "New task"
@pytest.mark.asyncio
async def test_update_task_patches(client):
from fable_mcp.tools.tasks import update_task
client.patch = AsyncMock(return_value={"id": 3, "status": "done"})
await update_task(client, task_id=3, status="done")
client.patch.assert_called_once()
assert "/api/tasks/3" in client.patch.call_args[0][0]
@pytest.mark.asyncio
async def test_add_task_log_posts_to_logs_endpoint(client):
from fable_mcp.tools.tasks import add_task_log
client.post = AsyncMock(return_value={"id": 1, "body": "progress"})
await add_task_log(client, task_id=9, body="progress")
client.post.assert_called_once()
path = client.post.call_args[0][0]
assert path == "/api/tasks/9/logs"
assert client.post.call_args[1]["json"]["body"] == "progress"
+226 -3
View File
@@ -32,7 +32,7 @@ const appVersion = ref('dev');
const restoreFileInput = ref<HTMLInputElement | null>(null);
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "config", "users", "logs", "groups"]);
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "apikeys", "config", "users", "logs", "groups"]);
const _stored = localStorage.getItem("settings_tab") ?? "general";
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
watch(activeTab, (v) => {
@@ -41,8 +41,104 @@ watch(activeTab, (v) => {
if (v === "logs" && authStore.isAdmin) loadLogsPanel();
if (v === "groups" && authStore.isAdmin) loadGroupsPanel();
if (v === "briefing") loadBriefingTab();
if (v === "apikeys") fetchApiKeys();
});
// 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 apiKeyCopied = ref(false);
const creatingApiKey = ref(false);
const revokeConfirmId = 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 createApiKey() {
if (!newKeyName.value) return;
creatingApiKey.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 {
creatingApiKey.value = false;
}
}
async function revokeApiKey(id: number) {
await fetch(`/api/api-keys/${id}`, { method: 'DELETE', credentials: 'include' });
revokeConfirmId.value = null;
await fetchApiKeys();
}
async function copyApiKey() {
try {
await navigator.clipboard.writeText(newKeyValue.value);
} catch {
// Fallback for http (non-secure) contexts where clipboard API is unavailable
const ta = document.createElement('textarea');
ta.value = newKeyValue.value;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
apiKeyCopied.value = true;
setTimeout(() => { apiKeyCopied.value = false; }, 2000);
}
function _downloadFile(filename: string, content: string, mimeType = 'text/plain') {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function downloadEnvFile() {
const baseUrl = window.location.origin;
const content = `FABLE_URL=${baseUrl}\nFABLE_API_KEY=${newKeyValue.value}\n`;
_downloadFile('fable-mcp.env', content);
}
function downloadMcpConfig() {
const baseUrl = window.location.origin;
const config = {
mcpServers: {
fable: {
command: 'fable-mcp',
env: {
FABLE_URL: baseUrl,
FABLE_API_KEY: newKeyValue.value,
},
},
},
};
_downloadFile('fable-mcp-config.json', JSON.stringify(config, null, 2), 'application/json');
}
// Groups management
const groups = ref<GroupEntry[]>([]);
const groupsLoading = ref(false);
@@ -336,6 +432,7 @@ onMounted(async () => {
// base URL not configured yet
}
if (activeTab.value === "groups") loadGroupsPanel();
if (activeTab.value === "briefing") loadBriefingTab();
}
});
@@ -870,12 +967,12 @@ function formatUserDate(iso: string): string {
<div class="sidebar-group">
<div class="sidebar-group-label">User</div>
<button
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'briefing']"
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'briefing', 'apikeys']"
:key="tab"
:class="['sidebar-item', { active: activeTab === tab }]"
@click="activeTab = tab"
>
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
{{ tab === 'apikeys' ? 'API Keys' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
</button>
</div>
<div v-if="authStore.isAdmin" class="sidebar-group">
@@ -1457,6 +1554,69 @@ function formatUserDate(iso: string): string {
</div>
<!-- ── API Keys ── -->
<div v-show="activeTab === 'apikeys'" class="settings-grid">
<section class="settings-section full-width">
<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="createApiKey" :disabled="!newKeyName || creatingApiKey" class="btn btn-primary">
{{ creatingApiKey ? 'Generating' : 'Generate Key' }}
</button>
</div>
<!-- One-time key reveal -->
<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="copyApiKey" class="btn btn-secondary btn-sm">{{ apiKeyCopied ? 'Copied!' : 'Copy' }}</button>
</div>
<div style="display:flex; gap:0.5rem; margin-top: 0.5rem;">
<button @click="downloadEnvFile" class="btn btn-secondary btn-sm">Download .env</button>
<button @click="downloadMcpConfig" class="btn btn-secondary btn-sm">Download Claude config</button>
<button @click="newKeyValue = ''" class="btn btn-secondary">Done</button>
</div>
</div>
<!-- Keys table -->
<table v-if="apiKeys.length > 0" 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>
<span v-if="revokeConfirmId !== key.id">
<button @click="revokeConfirmId = key.id" class="btn btn-secondary btn-sm">Revoke</button>
</span>
<span v-else>
Sure?
<button @click="revokeApiKey(key.id)" class="btn btn-danger btn-sm">Yes</button>
<button @click="revokeConfirmId = null" class="btn btn-secondary btn-sm">No</button>
</span>
</td>
</tr>
</tbody>
</table>
<p v-else-if="apiKeys.length === 0 && activeTab === 'apikeys'" class="settings-empty">No API keys yet.</p>
</section>
</div>
<!-- ── Admin ── -->
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
@@ -2876,4 +3036,67 @@ function formatUserDate(iso: string): string {
flex-wrap: wrap;
margin-top: 0.5rem;
}
/* API Keys tab */
.api-key-create-form {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.api-key-scope-select {
display: flex;
gap: 1rem;
}
.api-key-scope-select label {
display: flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
}
.api-key-reveal {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
border-radius: var(--radius-md);
padding: 1rem;
margin-bottom: 1.5rem;
}
.api-key-value-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.5rem;
}
.api-key-value {
flex: 1;
background: var(--color-surface-2, var(--color-surface));
padding: 0.4rem 0.6rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
word-break: break-all;
}
.api-keys-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.api-keys-table th, .api-keys-table td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
font-size: 0.9rem;
}
.api-keys-table th { font-weight: 600; opacity: 0.7; }
.scope-badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 9999px;
font-size: 0.78rem;
font-weight: 600;
}
.scope-badge.read { background: color-mix(in srgb, #3b82f6 15%, transparent); color: #3b82f6; }
.scope-badge.write { background: color-mix(in srgb, #10b981 15%, transparent); color: #10b981; }
.settings-empty { opacity: 0.5; margin-top: 1rem; }
.settings-description { opacity: 0.7; margin-bottom: 1rem; line-height: 1.5; }
</style>
+4
View File
@@ -27,6 +27,8 @@ from fabledassistant.routes.groups import groups_bp
from fabledassistant.routes.shares import shares_bp
from fabledassistant.routes.in_app_notifications import notifications_bp
from fabledassistant.routes.users import users_bp
from fabledassistant.routes.api_keys import api_keys_bp
from fabledassistant.routes.search import search_bp
STATIC_DIR = Path(__file__).parent / "static"
logger = logging.getLogger(__name__)
@@ -77,6 +79,8 @@ def create_app() -> Quart:
app.register_blueprint(shares_bp)
app.register_blueprint(notifications_bp)
app.register_blueprint(users_bp)
app.register_blueprint(api_keys_bp)
app.register_blueprint(search_bp)
@app.before_request
async def before_request():
+23 -1
View File
@@ -1,13 +1,35 @@
import functools
from quart import g, jsonify, session
from quart import g, jsonify, request, session
from fabledassistant.services.auth import get_user_by_id
from fabledassistant.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
+1
View File
@@ -40,3 +40,4 @@ from fabledassistant.models.share import NoteShare, ProjectShare # noqa: E402,
from fabledassistant.models.notification import Notification # noqa: E402, F401
from fabledassistant.models.rss_feed import RssFeed, RssItem # noqa: E402, F401
from fabledassistant.models.weather_cache import WeatherCache # noqa: E402, F401
from fabledassistant.models.api_key import ApiKey # noqa: E402, F401
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.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,
}
+42
View File
@@ -0,0 +1,42 @@
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.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
+5 -1
View File
@@ -75,7 +75,11 @@ async def create_conversation_route():
data = await request.get_json(force=True, silent=True) or {}
title = data.get("title", "")
model = data.get("model", Config.OLLAMA_MODEL)
conv = await create_conversation(uid, title=title, model=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
+46
View File
@@ -0,0 +1,46 @@
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.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),
})
+9
View File
@@ -1,8 +1,11 @@
import asyncio
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.models.note import TaskPriority, TaskStatus
from fabledassistant.routes.utils import not_found, parse_iso_date
from fabledassistant.services.embeddings import upsert_note_embedding
from fabledassistant.services.notes import (
create_note,
delete_note,
@@ -93,6 +96,9 @@ async def create_task_route():
milestone_id=data.get("milestone_id"),
parent_id=data.get("parent_id"),
)
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
if text:
asyncio.create_task(upsert_note_embedding(task.id, uid, text))
return jsonify(task.to_dict()), 201
@@ -147,6 +153,9 @@ async def update_task_route(task_id: int):
task = await update_note(uid, task_id, **fields)
if task is None:
return not_found("Task")
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
if text:
asyncio.create_task(upsert_note_embedding(task.id, uid, text))
return jsonify(task.to_dict())
+87
View File
@@ -0,0 +1,87 @@
import hashlib
import secrets
from datetime import datetime, timezone
from sqlalchemy import select
from fabledassistant.models import async_session
from fabledassistant.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
@@ -80,7 +80,7 @@ async def _gather_internal(user_id: int) -> dict:
# Calendar events today
calendar_events = []
try:
if is_caldav_configured(user_id):
if await is_caldav_configured(user_id):
events = await list_events(user_id, start=today, end=today)
calendar_events = [
f"{e.get('summary', 'Event')} at {e.get('dtstart', 'unknown time')}"
+7 -3
View File
@@ -15,10 +15,10 @@ logger = logging.getLogger(__name__)
async def create_conversation(
user_id: int, title: str = "", model: str = ""
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)
conv = Conversation(user_id=user_id, title=title, model=model, conversation_type=conversation_type)
session.add(conv)
await session.commit()
# Re-fetch with messages eagerly loaded to avoid lazy-load in async context
@@ -128,7 +128,11 @@ async def cleanup_old_conversations(user_id: int, days: int) -> int:
async with async_session() as session:
result = await session.execute(
sa_delete(Conversation)
.where(Conversation.user_id == user_id, Conversation.updated_at < cutoff)
.where(
Conversation.user_id == user_id,
Conversation.updated_at < cutoff,
Conversation.conversation_type != "mcp", # preserve MCP audit trail
)
.returning(Conversation.id)
)
await session.commit()
-1
View File
@@ -157,7 +157,6 @@ async def list_notes(
if no_project:
query = query.where(Note.project_id.is_(None))
count_query = count_query.where(Note.project_id.is_(None))
count_query = count_query.where(Note.parent_id == parent_id)
sort_col = getattr(Note, sort, Note.updated_at)
if order == "asc":
+49 -64
View File
@@ -30,6 +30,35 @@ def _schedule_embedding(note_id: int, user_id: int, title: str, body: str) -> No
asyncio.create_task(upsert_note_embedding(note_id, user_id, text))
async def _resolve_project(user_id: int, project_name: str):
"""Exact-then-fuzzy project lookup. Returns the Project or None.
Resolution order:
1. Exact title match (case-insensitive via DB)
2. project_name is a substring of an existing title ("Famous Supply" in "Famous Supply Co.")
3. Existing title is a substring of project_name ("Famous Supply Co." in "Famous Supply Co. UK")
4. SequenceMatcher ratio >= 0.55 (catches abbreviations / partial names)
"""
from fabledassistant.services.projects import get_project_by_title, list_projects
proj = await get_project_by_title(user_id, project_name)
if proj is not None:
return proj
needle = project_name.lower().strip()
all_p = await list_projects(user_id)
# Substring checks first (deterministic)
for p in all_p:
haystack = p.title.lower().strip()
if needle in haystack or haystack in needle:
return p
# SequenceMatcher fallback for abbreviations / partial names
best, best_r = None, 0.0
for p in all_p:
r = SequenceMatcher(None, needle, p.title.lower().strip()).ratio()
if r >= 0.55 and r > best_r:
best, best_r = p, r
return best
# Core tools — always available
_CORE_TOOLS = [
{
@@ -358,7 +387,7 @@ _CORE_TOOLS = [
"type": "function",
"function": {
"name": "create_project",
"description": "Create a new project. IMPORTANT: You MUST ask the user to confirm the project name and intent before calling this tool. Only call with confirmed=true after the user has explicitly approved creation.",
"description": "Create a brand-new project. IMPORTANT: NEVER call this because a project name was not found — always use list_projects first to confirm no similar project exists. Only call this when the user has explicitly asked to create a new project AND confirmed it. Call with confirmed=true only after the user has explicitly approved. The system will block creation if any project with a similar name already exists.",
"parameters": {
"type": "object",
"properties": {
@@ -860,14 +889,9 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
parent_task_name = arguments.get("parent_task")
pre_fields: dict = {}
if project_name:
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
proj = await _gpbt(user_id, project_name)
proj = await _resolve_project(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects or create_project to create one."}
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to find the correct project name and retry with the exact name."}
pre_fields["project_id"] = proj.id
if milestone_name:
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt
@@ -942,14 +966,9 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
project_name = arguments.get("project")
project_id = None
if project_name:
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
proj = await _gpbt(user_id, project_name)
proj = await _resolve_project(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects or create_project to create one."}
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to find the correct project name and retry with the exact name."}
project_id = proj.id
existing_notes, _ = await list_notes(user_id=user_id, q=note_title, is_task=False, limit=1)
@@ -1042,12 +1061,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
if "project" in arguments:
project_name = arguments["project"]
if project_name:
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
proj = await _gpbt(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
proj = await _resolve_project(user_id, project_name)
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects."}
update_fields["project_id"] = proj.id
@@ -1082,13 +1096,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
project_name = arguments.get("project")
milestone_name = arguments.get("milestone")
if project_name:
from fabledassistant.services.projects import get_project_by_title, list_projects as _lp
proj = await get_project_by_title(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
if matches:
proj = matches[0]
proj = await _resolve_project(user_id, project_name)
if proj:
project_id = proj.id
if milestone_name:
@@ -1150,12 +1158,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
# Resolve project name → id
search_project_id: int | None = None
if project_name:
from fabledassistant.services.projects import get_project_by_title as _gpbt2, list_projects as _lp2
proj = await _gpbt2(user_id, project_name)
if proj is None:
all_p = await _lp2(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
proj = await _resolve_project(user_id, project_name)
if proj:
search_project_id = proj.id
@@ -1334,12 +1337,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
project_id = None
project_name = arguments.get("project")
if project_name:
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
proj = await _gpbt(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
proj = await _resolve_project(user_id, project_name)
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found."}
project_id = proj.id
@@ -1412,7 +1410,6 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
}
elif tool_name == "create_milestone":
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
from fabledassistant.services.milestones import create_milestone as _cm, get_project_milestone_summary
project_name = arguments.get("project", "")
ms_title_pre = arguments.get("title", "Untitled Milestone")
@@ -1420,13 +1417,9 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
return {"success": False, "error": "project is required"}
if not arguments.get("confirmed"):
return {"success": False, "requires_confirmation": True, "error": f"Milestone creation requires explicit user confirmation. Ask the user: 'Shall I create a milestone titled \"{ms_title_pre}\" in project \"{project_name}\"?' Then retry with confirmed=true if they agree."}
proj = await _gpbt(user_id, project_name)
proj = await _resolve_project(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects or create_project to create one."}
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to find the correct project name and retry with the exact name."}
ms_title = ms_title_pre
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt2, list_milestones as _lms
existing_ms = await _gmbt2(user_id, proj.id, ms_title)
@@ -1445,17 +1438,12 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
return {"success": True, "type": "milestone", "data": ms.to_dict()}
elif tool_name == "update_milestone":
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt, update_milestone as _um
project_name = arguments.get("project", "")
milestone_name = arguments.get("milestone", "")
if not project_name or not milestone_name:
return {"success": False, "error": "Both project and milestone are required"}
proj = await _gpbt(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
proj = await _resolve_project(user_id, project_name)
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found"}
ms = await _gmbt(user_id, proj.id, milestone_name)
@@ -1474,14 +1462,9 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
return {"success": True, "type": "milestone", "data": updated.to_dict()}
elif tool_name == "list_milestones":
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
from fabledassistant.services.milestones import get_project_milestone_summary
project_name = arguments.get("project", "")
proj = await _gpbt(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
proj = await _resolve_project(user_id, project_name)
if proj is None:
return {"success": False, "error": f"No project found matching '{project_name}'"}
summary = await get_project_milestone_summary(user_id, proj.id)
@@ -1520,15 +1503,17 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
elif tool_name == "create_project":
from fabledassistant.services.projects import create_project as _create_project, get_project_by_title as _gpbt, list_projects as _lp
proj_title = arguments.get("title", "New Project")
if not arguments.get("confirmed"):
return {"success": False, "requires_confirmation": True, "error": f"Project creation requires explicit user confirmation. Ask the user: 'Shall I create a new project titled \"{proj_title}\"?' Then retry with confirmed=true if they agree."}
# Always check for existing/similar projects first — even when confirmed=true
existing_proj = await _gpbt(user_id, proj_title)
if existing_proj is not None:
return {"success": False, "error": f"A project titled '{existing_proj.title}' already exists (id: {existing_proj.id}). Use update_project to modify it instead of creating a duplicate."}
return {"success": False, "error": f"A project titled '{existing_proj.title}' already exists (id: {existing_proj.id}). Use that project instead of creating a duplicate."}
all_projects = await _lp(user_id)
near_proj, ratio = _fuzzy_title_match(proj_title, all_projects)
# Broad fuzzy check (0.55) catches partial names and abbreviations
near_proj, ratio = _fuzzy_title_match(proj_title, all_projects, threshold=0.55)
if near_proj is not None:
return {"success": False, "error": f"A project with a very similar title '{near_proj.title}' already exists (id: {near_proj.id}, similarity: {ratio:.0%}). Use update_project to modify it instead of creating a duplicate."}
return {"success": False, "requires_confirmation": True, "error": f"A project with a similar name '{near_proj.title}' already exists (id: {near_proj.id}, similarity: {ratio:.0%}). This is likely the project you meant — use that project's name instead. Only call create_project again if you are certain this should be a completely new, separate project with a different confirmed=true."}
if not arguments.get("confirmed"):
return {"success": False, "requires_confirmation": True, "error": f"Project creation requires explicit user confirmation. Ask the user: 'Shall I create a new project titled \"{proj_title}\"?' Then retry with confirmed=true if they agree."}
project = await _create_project(
user_id,
title=proj_title,
+14 -1
View File
@@ -12,7 +12,20 @@
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
2026-03-12Daily Briefing feature (full stack), CI release process fix, CalVer version tracking, content deduplication, task history improvements.
2026-03-16Fuzzy project resolution, accidental project creation guard, task embedding, briefing CalDAV fix, notes count query fix.
---
**2026-03-16 — Fuzzy project resolution + create_project guard:**
- `services/tools.py``_resolve_project` rewritten with 4-step lookup: (1) exact DB match, (2) `query in title` substring, (3) `title in query` reverse substring, (4) SequenceMatcher ≥ 0.55 fallback. Partial names like "Famous Supply" now reliably resolve to "Famous Supply Co." without the model creating a duplicate project.
- `services/tools.py``create_project` handler: similar-project checks (threshold 0.55) now run **before** the `confirmed` gate, so `confirmed=true` cannot bypass them. All call sites that duplicated the old inline substring logic replaced with `_resolve_project`.
- `services/tools.py` — Error messages on project-not-found no longer say "or create_project to create one"; they direct the model to `list_projects` instead. Tool description explicitly says never call `create_project` in response to a lookup failure.
- `routes/tasks.py` — Fire-and-forget `upsert_note_embedding` added to create and update task routes (was missing; tasks created via REST API weren't getting embeddings).
- `services/briefing_pipeline.py``await is_caldav_configured()` (was called without await — was a latent bug since `is_caldav_configured` became async).
- `services/notes.py` — Removed erroneous duplicate `count_query.where(Note.parent_id == parent_id)` that was applied inside the `no_project` branch.
---
**CI release process (2026-03-12):**
- `.forgejo/workflows/ci.yml`: removed `main` from `branches` push trigger. CI now fires only on `dev` pushes, `v*` tags, and pull requests. Main branch merges no longer trigger a run — the PR check covers pre-merge safety. Production release process: create a release via Forgejo UI on `main` with a `v*` tag → tag push event fires CI → build job pushes `:latest` + `:<version>` Docker images to registry. Branch protection is not an obstacle because the UI release creates the tag through the API.
+166
View File
@@ -0,0 +1,166 @@
"""Unit tests for api_keys service — pure logic, DB calls mocked."""
import hashlib
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fabledassistant.services.api_keys import (
_hash_key,
_key_prefix,
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
def test_generate_key_uniqueness():
keys = {generate_key() for _ in range(100)}
assert len(keys) == 100
def test_hash_key_is_sha256():
key = "fmcp_testkey"
h = _hash_key(key)
expected = hashlib.sha256(key.encode()).hexdigest()
assert h == expected
def test_key_prefix_returns_first_12_chars():
key = "fmcp_abcdefghijklmnop"
assert _key_prefix(key) == "fmcp_abcdefg"
@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("fabledassistant.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.refresh = AsyncMock(side_effect=lambda obj: None)
# Return our fake object from refresh
mock_session.refresh = AsyncMock()
mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"}
mock_session_ctx.return_value = mock_session
# Patch the select result
with patch("fabledassistant.services.api_keys.ApiKey") as mock_model:
mock_model.return_value = mock_key_obj
full_key, key_dict = await create_api_key(user_id=1, name="test", scope="read")
assert full_key.startswith("fmcp_")
@pytest.mark.asyncio
async def test_lookup_key_returns_none_for_unknown():
with patch("fabledassistant.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
def test_hash_key_deterministic():
key = "fmcp_some_test_key_value"
assert _hash_key(key) == _hash_key(key)
@pytest.mark.asyncio
async def test_scope_validation():
with pytest.raises(ValueError, match="scope must be"):
await create_api_key(1, "bad", "superadmin")
# --- Auth middleware tests ---
@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, patch
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__)
async def dummy():
from quart import jsonify
return jsonify({"ok": True})
from fabledassistant.auth import _check_auth
wrapped = _check_auth(dummy)
async with app.test_request_context(
"/test", method="POST",
headers={"Authorization": "Bearer fmcp_readonly"},
):
with patch("fabledassistant.auth.lookup_key", AsyncMock(return_value=fake_key)), \
patch("fabledassistant.auth.get_user_by_id", AsyncMock(return_value=fake_user)):
resp = await wrapped()
assert resp[1] == 403
@pytest.mark.asyncio
async def test_bearer_token_valid_write_key_passes():
"""Valid write-scoped bearer token passes auth on POST."""
from unittest.mock import AsyncMock, MagicMock, patch
from quart import Quart, g
fake_key = MagicMock()
fake_key.user_id = 7
fake_key.scope = "write"
fake_user = MagicMock()
fake_user.role = "user"
fake_user.id = 7
app = Quart(__name__)
result_holder = {}
async def dummy():
result_holder["user"] = g.user
result_holder["api_key"] = g.api_key
from quart import jsonify
return jsonify({"ok": True})
from fabledassistant.auth import _check_auth
wrapped = _check_auth(dummy)
async with app.test_request_context(
"/test", method="POST",
headers={"Authorization": "Bearer fmcp_writekey"},
):
with patch("fabledassistant.auth.lookup_key", AsyncMock(return_value=fake_key)), \
patch("fabledassistant.auth.get_user_by_id", AsyncMock(return_value=fake_user)):
resp = await wrapped()
# Should not be a tuple (no error response)
assert not isinstance(resp, tuple) or resp[1] == 200
assert result_holder.get("user") is fake_user
assert result_holder.get("api_key") is fake_key
+18
View File
@@ -0,0 +1,18 @@
"""Unit tests for the search route parameter mapping."""
from fabledassistant.routes.search import _content_type_to_is_task
def test_content_type_note():
assert _content_type_to_is_task("note") is False
def test_content_type_task():
assert _content_type_to_is_task("task") is True
def test_content_type_all():
assert _content_type_to_is_task("all") is None
def test_content_type_unknown_defaults_to_all():
assert _content_type_to_is_task("unknown") is None