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:
@@ -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.
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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=<your-api-key></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>
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user