From 553c38200ac7c9cd769b6984cab4707c1c87e3df Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 24 Mar 2026 17:26:05 -0400 Subject: [PATCH] feat(fable-mcp): build wheel in Docker image, serve download, add admin log tool, and Settings install UI - Dockerfile: build fable-mcp wheel into /app/dist/ during image build - routes/fable_mcp_dist.py: GET /api/fable-mcp/info + /download endpoints - app.py: register fable_mcp_dist_bp - fable_mcp/tools/admin.py: get_app_logs() hitting /api/admin/logs - fable_mcp/server.py: fable_get_app_logs MCP tool - SettingsView: "Fable MCP" section in API Keys tab with download button and install instructions - client.ts: getFableMcpInfo() helper - ci.yml: add fable-mcp/** to trigger paths Co-Authored-By: Claude Sonnet 4.6 --- .forgejo/workflows/ci.yml | 1 + Dockerfile | 7 ++ fable-mcp/fable_mcp/server.py | 27 ++++- fable-mcp/fable_mcp/tools/admin.py | 20 ++++ frontend/src/api/client.ts | 4 + frontend/src/views/SettingsView.vue | 102 ++++++++++++++++++- src/fabledassistant/app.py | 2 + src/fabledassistant/routes/fable_mcp_dist.py | 42 ++++++++ 8 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 fable-mcp/fable_mcp/tools/admin.py create mode 100644 src/fabledassistant/routes/fable_mcp_dist.py diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 6e6551a..557f119 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -26,6 +26,7 @@ on: - "alembic.ini" - "Dockerfile" - "assets/**" + - "fable-mcp/**" - ".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. diff --git a/Dockerfile b/Dockerfile index 699e156..d2f5e0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,13 @@ COPY pyproject.toml . COPY src/ src/ RUN pip install --no-cache-dir . +# Build the fable-mcp wheel so it can be served for download +COPY fable-mcp/ fable-mcp/ +RUN pip install --no-cache-dir build hatchling \ + && python -m build --wheel ./fable-mcp --outdir /app/dist/ \ + && pip uninstall -y build \ + && rm -rf fable-mcp/ /root/.cache/pip + COPY --from=build-frontend /build/dist/ src/fabledassistant/static/ COPY alembic.ini . COPY alembic/ alembic/ diff --git a/fable-mcp/fable_mcp/server.py b/fable-mcp/fable_mcp/server.py index 0d46ad3..492d068 100644 --- a/fable-mcp/fable_mcp/server.py +++ b/fable-mcp/fable_mcp/server.py @@ -5,7 +5,7 @@ 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 +from fable_mcp.tools import notes, tasks, projects, milestones, search, chat, admin load_dotenv() @@ -341,6 +341,31 @@ async def fable_send_message( ) +# --------------------------------------------------------------------------- +# Admin / observability +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def fable_get_app_logs( + category: str = "error", + limit: int = 20, + search: str = "", +) -> dict: + """Fetch Fable application logs. Requires an admin API key. + + category: "error" (default) | "audit" | "usage" + search: optional keyword filter applied to action, endpoint, username, and details + """ + async with FableClient() as client: + return await admin.get_app_logs( + client, + category=category, + limit=limit, + search=search or None, + ) + + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- diff --git a/fable-mcp/fable_mcp/tools/admin.py b/fable-mcp/fable_mcp/tools/admin.py new file mode 100644 index 0000000..4b2aa5e --- /dev/null +++ b/fable-mcp/fable_mcp/tools/admin.py @@ -0,0 +1,20 @@ +"""Admin tools: application log access (requires admin API key).""" +from __future__ import annotations + +from fable_mcp.client import FableClient + + +async def get_app_logs( + client: FableClient, + category: str = "error", + limit: int = 20, + search: str | None = None, +) -> dict: + """Fetch application logs from Fable. Requires an admin-scoped API key. + + category: "error" | "audit" | "usage" (default: "error") + """ + params: dict = {"category": category, "limit": limit} + if search: + params["search"] = search + return await client.get("/api/admin/logs", params=params) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 03efb8a..b24f8a8 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -426,6 +426,10 @@ export async function geocodeAddress(address: string): Promise<{ lat: number; lo } } +export async function getFableMcpInfo(): Promise<{ available: boolean; filename: string | null }> { + return apiGet('/api/fable-mcp/info'); +} + export async function apiStreamPost( path: string, body: unknown, diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 2083e3b..7dbe453 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -3,7 +3,7 @@ import { ref, watch, onMounted } from "vue"; import { useSettingsStore } from "@/stores/settings"; import { useAuthStore } from "@/stores/auth"; import { useToastStore } from "@/stores/toast"; -import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, refreshBriefingFeeds, geocodeAddress, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed } from "@/api/client"; +import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, refreshBriefingFeeds, geocodeAddress, getFableMcpInfo, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed } from "@/api/client"; import { usePushStore } from "@/stores/push"; import type { User } from "@/types/auth"; import PaginationBar from "@/components/PaginationBar.vue"; @@ -48,7 +48,7 @@ watch(activeTab, (v) => { if (v === "logs" && authStore.isAdmin) loadLogsPanel(); if (v === "groups" && authStore.isAdmin) loadGroupsPanel(); if (v === "briefing") loadBriefingTab(); - if (v === "apikeys") fetchApiKeys(); + if (v === "apikeys") { fetchApiKeys(); loadMcpInfo(); } }); // API Keys @@ -59,6 +59,33 @@ const newKeyValue = ref(''); const apiKeyCopied = ref(false); const creatingApiKey = ref(false); const revokeConfirmId = ref(null); +const mcpInfo = ref<{ available: boolean; filename: string | null } | null>(null); +const mcpInfoLoading = ref(false); + +const origin = window.location.origin; +const mcpConfigSnippet = JSON.stringify({ + mcpServers: { + fable: { + command: "fable-mcp", + env: { + FABLE_URL: window.location.origin, + FABLE_API_KEY: "", + }, + }, + }, +}, null, 2); + +async function loadMcpInfo() { + if (mcpInfo.value !== null) return; + mcpInfoLoading.value = true; + try { + mcpInfo.value = await getFableMcpInfo(); + } catch { + mcpInfo.value = { available: false, filename: null }; + } finally { + mcpInfoLoading.value = false; + } +} async function fetchApiKeys() { const res = await fetch('/api/api-keys', { credentials: 'include' }); @@ -1818,6 +1845,47 @@ function formatUserDate(iso: string): string {

No API keys yet.

+ + +
+

Fable MCP

+

+ The Fable MCP server lets Claude (and other MCP clients) read and write your notes, tasks, and projects. + Install it locally, then point it at this Fable instance with an API key above. +

+ +
Checking package availability…
+ +
@@ -3411,4 +3479,34 @@ function formatUserDate(iso: string): string { .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; } + +/* Fable MCP section */ +.mcp-status { opacity: 0.6; font-size: 0.9rem; } +.mcp-unavailable p { opacity: 0.7; } +.mcp-available { display: flex; flex-direction: column; gap: 1.25rem; } +.mcp-pkg-row { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.65rem 0.9rem; + background: color-mix(in srgb, var(--color-primary) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent); + border-radius: 8px; +} +.mcp-pkg-name { font-family: monospace; font-size: 0.9rem; flex: 1; } +.mcp-install-steps h3 { font-size: 0.95rem; font-weight: 600; margin-bottom: 0.75rem; } +.mcp-install-steps ol { padding-left: 1.25rem; display: flex; flex-direction: column; gap: 0.75rem; } +.mcp-install-steps li { line-height: 1.6; font-size: 0.9rem; } +.mcp-code { + margin-top: 0.4rem; + padding: 0.55rem 0.75rem; + background: color-mix(in srgb, var(--color-text) 6%, transparent); + border: 1px solid var(--color-border); + border-radius: 6px; + font-size: 0.82rem; + font-family: monospace; + white-space: pre-wrap; + word-break: break-all; + overflow-x: auto; +} diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index 18013e5..16297d1 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -20,6 +20,7 @@ from fabledassistant.routes.milestones import milestones_bp from fabledassistant.routes.task_logs import task_logs_bp from fabledassistant.routes.projects import projects_bp from fabledassistant.routes.push import push_bp +from fabledassistant.routes.fable_mcp_dist import fable_mcp_dist_bp from fabledassistant.routes.quick_capture import quick_capture_bp from fabledassistant.routes.settings import settings_bp from fabledassistant.routes.tasks import tasks_bp @@ -69,6 +70,7 @@ def create_app() -> Quart: app.register_blueprint(images_bp) app.register_blueprint(milestones_bp) app.register_blueprint(notes_bp) + app.register_blueprint(fable_mcp_dist_bp) app.register_blueprint(projects_bp) app.register_blueprint(push_bp) app.register_blueprint(quick_capture_bp) diff --git a/src/fabledassistant/routes/fable_mcp_dist.py b/src/fabledassistant/routes/fable_mcp_dist.py new file mode 100644 index 0000000..0136c89 --- /dev/null +++ b/src/fabledassistant/routes/fable_mcp_dist.py @@ -0,0 +1,42 @@ +"""Serve the fable-mcp distribution wheel built into the Docker image.""" +import logging +import os +from pathlib import Path + +from quart import Blueprint, jsonify, send_file + +from fabledassistant.auth import login_required + +logger = logging.getLogger(__name__) + +fable_mcp_dist_bp = Blueprint("fable_mcp_dist", __name__, url_prefix="/api/fable-mcp") + +# Wheel is built into the image at this path (see Dockerfile) +_DIST_DIR = Path(os.environ.get("FABLE_MCP_DIST_DIR", "/app/dist")) + + +def _find_wheel() -> Path | None: + """Return the newest fable_mcp wheel in the dist dir, or None.""" + wheels = sorted(_DIST_DIR.glob("fable_mcp-*.whl"), reverse=True) + return wheels[0] if wheels else None + + +@fable_mcp_dist_bp.route("/info", methods=["GET"]) +@login_required +async def fable_mcp_info(): + """Return availability and filename of the bundled fable-mcp wheel.""" + wheel = _find_wheel() + return jsonify({ + "available": wheel is not None, + "filename": wheel.name if wheel else None, + }) + + +@fable_mcp_dist_bp.route("/download", methods=["GET"]) +@login_required +async def download_fable_mcp(): + """Serve the fable-mcp wheel file as a download.""" + wheel = _find_wheel() + if wheel is None: + return jsonify({"error": "Package not built into this image"}), 404 + return await send_file(wheel, as_attachment=True)