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"
|
- "alembic.ini"
|
||||||
- "Dockerfile"
|
- "Dockerfile"
|
||||||
- "assets/**"
|
- "assets/**"
|
||||||
|
- "fable-mcp/**"
|
||||||
- ".forgejo/workflows/ci.yml"
|
- ".forgejo/workflows/ci.yml"
|
||||||
# pull_request trigger intentionally omitted — all changes go through dev
|
# pull_request trigger intentionally omitted — all changes go through dev
|
||||||
# first, where CI already runs on push. PR runs would be redundant duplication.
|
# first, where CI already runs on push. PR runs would be redundant duplication.
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ COPY pyproject.toml .
|
|||||||
COPY src/ src/
|
COPY src/ src/
|
||||||
RUN pip install --no-cache-dir .
|
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 --from=build-frontend /build/dist/ src/fabledassistant/static/
|
||||||
COPY alembic.ini .
|
COPY alembic.ini .
|
||||||
COPY alembic/ alembic/
|
COPY alembic/ alembic/
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from mcp.server.fastmcp import FastMCP
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from fable_mcp.client import FableClient
|
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()
|
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
|
# 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(
|
export async function apiStreamPost(
|
||||||
path: string,
|
path: string,
|
||||||
body: unknown,
|
body: unknown,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ref, watch, onMounted } from "vue";
|
|||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useToastStore } from "@/stores/toast";
|
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 { usePushStore } from "@/stores/push";
|
||||||
import type { User } from "@/types/auth";
|
import type { User } from "@/types/auth";
|
||||||
import PaginationBar from "@/components/PaginationBar.vue";
|
import PaginationBar from "@/components/PaginationBar.vue";
|
||||||
@@ -48,7 +48,7 @@ watch(activeTab, (v) => {
|
|||||||
if (v === "logs" && authStore.isAdmin) loadLogsPanel();
|
if (v === "logs" && authStore.isAdmin) loadLogsPanel();
|
||||||
if (v === "groups" && authStore.isAdmin) loadGroupsPanel();
|
if (v === "groups" && authStore.isAdmin) loadGroupsPanel();
|
||||||
if (v === "briefing") loadBriefingTab();
|
if (v === "briefing") loadBriefingTab();
|
||||||
if (v === "apikeys") fetchApiKeys();
|
if (v === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// API Keys
|
// API Keys
|
||||||
@@ -59,6 +59,33 @@ const newKeyValue = ref('');
|
|||||||
const apiKeyCopied = ref(false);
|
const apiKeyCopied = ref(false);
|
||||||
const creatingApiKey = ref(false);
|
const creatingApiKey = ref(false);
|
||||||
const revokeConfirmId = ref<number | null>(null);
|
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() {
|
async function fetchApiKeys() {
|
||||||
const res = await fetch('/api/api-keys', { credentials: 'include' });
|
const res = await fetch('/api/api-keys', { credentials: 'include' });
|
||||||
@@ -1818,6 +1845,47 @@ function formatUserDate(iso: string): string {
|
|||||||
</table>
|
</table>
|
||||||
<p v-else-if="apiKeys.length === 0 && activeTab === 'apikeys'" class="settings-empty">No API keys yet.</p>
|
<p v-else-if="apiKeys.length === 0 && activeTab === 'apikeys'" class="settings-empty">No API keys yet.</p>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- ── Admin ── -->
|
<!-- ── Admin ── -->
|
||||||
@@ -3411,4 +3479,34 @@ function formatUserDate(iso: string): string {
|
|||||||
.scope-badge.write { background: color-mix(in srgb, #10b981 15%, transparent); color: #10b981; }
|
.scope-badge.write { background: color-mix(in srgb, #10b981 15%, transparent); color: #10b981; }
|
||||||
.settings-empty { opacity: 0.5; margin-top: 1rem; }
|
.settings-empty { opacity: 0.5; margin-top: 1rem; }
|
||||||
.settings-description { opacity: 0.7; margin-bottom: 1rem; line-height: 1.5; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from fabledassistant.routes.milestones import milestones_bp
|
|||||||
from fabledassistant.routes.task_logs import task_logs_bp
|
from fabledassistant.routes.task_logs import task_logs_bp
|
||||||
from fabledassistant.routes.projects import projects_bp
|
from fabledassistant.routes.projects import projects_bp
|
||||||
from fabledassistant.routes.push import push_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.quick_capture import quick_capture_bp
|
||||||
from fabledassistant.routes.settings import settings_bp
|
from fabledassistant.routes.settings import settings_bp
|
||||||
from fabledassistant.routes.tasks import tasks_bp
|
from fabledassistant.routes.tasks import tasks_bp
|
||||||
@@ -69,6 +70,7 @@ def create_app() -> Quart:
|
|||||||
app.register_blueprint(images_bp)
|
app.register_blueprint(images_bp)
|
||||||
app.register_blueprint(milestones_bp)
|
app.register_blueprint(milestones_bp)
|
||||||
app.register_blueprint(notes_bp)
|
app.register_blueprint(notes_bp)
|
||||||
|
app.register_blueprint(fable_mcp_dist_bp)
|
||||||
app.register_blueprint(projects_bp)
|
app.register_blueprint(projects_bp)
|
||||||
app.register_blueprint(push_bp)
|
app.register_blueprint(push_bp)
|
||||||
app.register_blueprint(quick_capture_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