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 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 17:26:05 -04:00
parent 6564a03c0e
commit 553c38200a
8 changed files with 202 additions and 3 deletions
+1
View File
@@ -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.
+7
View File
@@ -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/
+26 -1
View File
@@ -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
# ---------------------------------------------------------------------------
+20
View File
@@ -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)
+4
View File
@@ -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,
+100 -2
View File
@@ -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<number | null>(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: "<your-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 {
</table>
<p v-else-if="apiKeys.length === 0 && activeTab === 'apikeys'" class="settings-empty">No API keys yet.</p>
</section>
<!-- Fable MCP install section -->
<section class="settings-section full-width">
<h2>Fable MCP</h2>
<p class="settings-description">
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.
</p>
<div v-if="mcpInfoLoading" class="mcp-status">Checking package availability…</div>
<template v-else-if="mcpInfo">
<div v-if="mcpInfo.available" class="mcp-available">
<div class="mcp-pkg-row">
<span class="mcp-pkg-name">{{ mcpInfo.filename }}</span>
<a href="/api/fable-mcp/download" class="btn btn-primary btn-sm" download>Download</a>
</div>
<div class="mcp-install-steps">
<h3>Installation</h3>
<ol>
<li>
Download the wheel above and install it:
<pre class="mcp-code">pip install {{ mcpInfo.filename }}</pre>
</li>
<li>
Create a <code>.env</code> file (or set environment variables):
<pre class="mcp-code">FABLE_URL={{ origin }}
FABLE_API_KEY=&lt;your-api-key&gt;</pre>
</li>
<li>
Add to your Claude MCP config (<code>~/.claude.json</code> or the Claude Desktop config):
<pre class="mcp-code">{{ mcpConfigSnippet }}</pre>
</li>
</ol>
</div>
</div>
<div v-else class="mcp-unavailable">
<p>The Fable MCP package is not bundled in this image build. It will be available after the next image rebuild.</p>
</div>
</template>
</section>
</div>
<!-- ── Admin ── -->
@@ -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;
}
</style>
+2
View File
@@ -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)
@@ -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)