From 2fd914916fc91948d5d6e5c4853cc9031670debd Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 10 Apr 2026 15:06:55 -0400 Subject: [PATCH] feat(fable-mcp): briefing introspection and manual trigger tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the MCP tools needed to debug the agentic briefing pipeline without waiting for scheduled slots to fire: - fable_list_briefings — list briefing conversations newest first - fable_get_today_briefing — fetch today's briefing with all messages - fable_get_briefing_messages(conv_id) — message list for a specific briefing conversation, including tool_calls with embedded results - fable_trigger_briefing(slot) — manually run a slot via POST /api/briefing/trigger (fires RSS/weather refresh, same as the scheduler) - fable_get_conversation(conv_id) — generic conversation read for chat or briefing threads, full messages + tool_calls + metadata All of these hit existing REST endpoints (/api/briefing/conversations, /api/briefing/conversations//messages, /api/briefing/trigger, /api/chat/conversations/) so no API surface changed — the gap was purely on the MCP side. Bearer token auth works across both blueprints because login_required already accepts API keys. Enables a full "trigger → inspect → tune prompt → repeat" loop from Claude without touching the UI, which is necessary for iterating on agentic briefing prompts when the scheduled slot is hours away. Co-Authored-By: Claude Opus 4.6 (1M context) --- fable-mcp/fable_mcp/server.py | 94 +++++++++++++++++++++++++++ fable-mcp/fable_mcp/tools/briefing.py | 48 +++++++++++++- 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/fable-mcp/fable_mcp/server.py b/fable-mcp/fable_mcp/server.py index 8e276d3..6c1150b 100644 --- a/fable-mcp/fable_mcp/server.py +++ b/fable-mcp/fable_mcp/server.py @@ -628,6 +628,100 @@ async def fable_remove_rss_feed(feed_id: int) -> dict: return await briefing.remove_rss_feed(client, feed_id=feed_id) +# --------------------------------------------------------------------------- +# Briefing introspection & control +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def fable_list_briefings() -> dict: + """List the user's briefing conversations, newest first. + + Each briefing has an associated date and conversation id. Use + ``fable_get_briefing_messages`` or ``fable_get_conversation`` with + the id to pull the actual briefing text and tool-call receipts. + + Returns a dict with a ``conversations`` list. + """ + async with FableClient() as client: + return await briefing.list_briefing_conversations(client) + + +@mcp.tool() +async def fable_get_today_briefing() -> dict: + """Fetch today's briefing conversation, creating it if needed. + + Returns the full conversation object including all messages — the + scheduled briefing assistant turns, any tool calls the agentic path + made, and any chat replies the user has sent in the briefing thread. + + Use this to inspect what a briefing actually said (and what tool + results grounded it) without having to query by id. + """ + async with FableClient() as client: + return await briefing.get_today_briefing(client) + + +@mcp.tool() +async def fable_get_briefing_messages(conversation_id: int) -> dict: + """Fetch all messages for a specific briefing conversation. + + Args: + conversation_id: The briefing conversation id from fable_list_briefings. + + Returns a dict with a ``messages`` list. Each message includes + role, content, tool_calls (with results), and metadata — the + metadata carries ``briefing_slot`` tags on agentic briefing turns. + """ + async with FableClient() as client: + return await briefing.get_briefing_messages( + client, conversation_id=conversation_id, + ) + + +@mcp.tool() +async def fable_trigger_briefing(slot: str = "compilation") -> dict: + """Manually run a briefing slot for the current user. + + Fires the same data refresh the scheduler does (RSS, weather), + runs the briefing pipeline (legacy or agentic based on the user's + ``briefing_mode`` setting), and writes the result into today's + briefing conversation. Use this to test prompt changes without + waiting for the next scheduled slot. + + Args: + slot: One of ``compilation`` (full morning, default), ``morning``, + ``midday``, or ``afternoon``. + + Returns a dict with ``conversation_id``, ``message_id``, and ``slot``. + """ + async with FableClient() as client: + return await briefing.trigger_briefing(client, slot=slot) + + +# --------------------------------------------------------------------------- +# Generic conversation access +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def fable_get_conversation(conversation_id: int) -> dict: + """Fetch any conversation (chat or briefing) with its full message list. + + Returns conversation metadata plus an ordered ``messages`` array. + Each message includes role, content, tool_calls (with results), + context_note_id, and msg_metadata. Tool calls are in the stored + flat format: ``[{"function": name, "arguments": {...}, "result": {...}}]``. + + Useful for debugging agentic briefings, inspecting chat history, + or verifying that a tool actually ran with the expected arguments. + """ + async with FableClient() as client: + return await briefing.get_conversation( + client, conversation_id=conversation_id, + ) + + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- diff --git a/fable-mcp/fable_mcp/tools/briefing.py b/fable-mcp/fable_mcp/tools/briefing.py index 6c21c7e..79f4ad5 100644 --- a/fable-mcp/fable_mcp/tools/briefing.py +++ b/fable-mcp/fable_mcp/tools/briefing.py @@ -1,4 +1,4 @@ -"""MCP tools for Fable RSS feed management.""" +"""MCP tools for Fable briefings and RSS feed management.""" from __future__ import annotations from typing import Any @@ -6,6 +6,8 @@ from typing import Any from fable_mcp.client import FableClient +# ── RSS feeds ──────────────────────────────────────────────────────────────── + async def list_rss_feeds(client: FableClient) -> dict[str, Any]: """List the user's RSS feeds.""" return await client.get("/api/briefing/feeds") @@ -30,3 +32,47 @@ async def add_rss_feed( async def remove_rss_feed(client: FableClient, *, feed_id: int) -> dict[str, Any]: """Remove an RSS feed by ID.""" return await client.delete(f"/api/briefing/feeds/{feed_id}") + + +# ── Briefings ──────────────────────────────────────────────────────────────── + +async def list_briefing_conversations(client: FableClient) -> dict[str, Any]: + """List the user's briefing conversations, newest first.""" + return await client.get("/api/briefing/conversations") + + +async def get_today_briefing(client: FableClient) -> dict[str, Any]: + """Fetch today's briefing conversation with all messages.""" + return await client.get("/api/briefing/conversations/today") + + +async def get_briefing_messages( + client: FableClient, + *, + conversation_id: int, +) -> dict[str, Any]: + """Fetch messages for a specific briefing conversation.""" + return await client.get(f"/api/briefing/conversations/{conversation_id}/messages") + + +async def trigger_briefing( + client: FableClient, + *, + slot: str = "compilation", +) -> dict[str, Any]: + """Manually trigger a briefing slot — fires data refresh and runs the pipeline. + + Slot is one of: compilation (full morning), morning, midday, afternoon. + """ + return await client.post("/api/briefing/trigger", json={"slot": slot}) + + +# ── Generic conversations ─────────────────────────────────────────────────── + +async def get_conversation( + client: FableClient, + *, + conversation_id: int, +) -> dict[str, Any]: + """Fetch any conversation (chat or briefing) with all messages and tool calls.""" + return await client.get(f"/api/chat/conversations/{conversation_id}")