c5498273c3
The MCP was still calling /api/briefing/* endpoints I deleted in the
journal hard-cut. Replaced the briefing tool surface with a journal
equivalent so external MCP clients can inspect and control the journal
the same way they could the briefing.
Changes:
- New fable_mcp/tools/journal.py — helpers for /api/journal/* endpoints
- Delete fable_mcp/tools/briefing.py — RSS endpoints are gone too
- server.py: drop fable_list_rss_feeds, fable_add_rss_feed, fable_remove_rss_feed,
fable_list_briefings, fable_get_today_briefing, fable_get_briefing_messages,
fable_trigger_briefing, fable_reset_today_briefing
- server.py: add fable_get_today_journal, fable_get_journal_day,
fable_list_journal_days, fable_trigger_journal_prep, fable_get_journal_config,
fable_list_moments
- server.py: fable_get_conversation now points at journal.get_conversation
(same /api/chat/conversations/{id} endpoint, just lives under journal helpers)
- Update _INSTRUCTIONS to describe the journal model (replacing the RSS/briefing
section) — explains the daily prep, moments, day payloads
- Update top-line docstring: "Fable Assistant" → "Fable Scribe"
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
758 lines
25 KiB
Python
758 lines
25 KiB
Python
"""Fable MCP server — exposes Fable Scribe as MCP tools via stdio transport."""
|
|
from __future__ import annotations
|
|
|
|
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, admin, journal
|
|
|
|
load_dotenv()
|
|
|
|
_INSTRUCTIONS = """
|
|
Fabled Scribe is a self-hosted second-brain and project management system with LLM integration.
|
|
|
|
## Data model
|
|
|
|
The hierarchy is: Project → Milestone → Task/Note.
|
|
|
|
- **Notes** and **Tasks** share the same underlying model. Tasks are notes with `is_task=True`.
|
|
The note tools (fable_*_note) operate on notes; the task tools (fable_*_task) operate on tasks.
|
|
Do not use note tools to manipulate tasks or vice versa.
|
|
|
|
- **Projects** group related work. A project has a title, description, goal, status, and an
|
|
auto-generated summary used for semantic search. Status values: `active`, `archived`.
|
|
|
|
- **Milestones** belong to a project and group tasks within it. Status values: `active`, `done`.
|
|
|
|
- **Tasks** belong to a project and optionally a milestone. They support sub-tasks via `parent_id`.
|
|
- Status values: `todo`, `in_progress`, `done`, `cancelled`
|
|
- Priority values: `none` (default), `low`, `medium`, `high`
|
|
|
|
- **Notes** are free-form markdown documents. They can belong to a project or be standalone
|
|
(orphan notes). Orphan notes are included in the default RAG scope for chat conversations.
|
|
|
|
## Tags
|
|
|
|
Tags are plain strings — do NOT include a `#` prefix. Example: `["python", "architecture"]`.
|
|
Tags are stored as an array on the note/task. Passing `tags=[]` clears all tags; omitting `tags`
|
|
leaves existing tags unchanged on updates.
|
|
|
|
## Integer-or-none fields
|
|
|
|
Due to MCP type constraints, optional integer fields (project_id, milestone_id, parent_id)
|
|
use `0` to mean "not set / no association". Pass `0` to leave the field unset.
|
|
|
|
## Search
|
|
|
|
`fable_search` performs semantic (embedding-based) search over notes and tasks. Use it to find
|
|
relevant content by meaning rather than exact keywords. Returns results ranked by cosine
|
|
similarity with id, title, a body snippet, and tags.
|
|
|
|
## Chat / LLM delegation
|
|
|
|
`fable_send_message` sends a natural-language message to Fable's built-in LLM (Ollama). Fable
|
|
handles its own tool use, RAG context injection, and conversation history internally.
|
|
|
|
Use `fable_send_message` when:
|
|
- The request is conversational or requires Fable's internal reasoning across many records
|
|
- You want Fable's RAG to surface relevant notes automatically
|
|
|
|
Use the direct CRUD tools when:
|
|
- You know exactly what to create/read/update/delete
|
|
- You need structured data back (IDs, field values) for further processing
|
|
- You are populating Fable programmatically from another system
|
|
|
|
## Task logs
|
|
|
|
Use `fable_add_task_log` to append time-stamped progress notes to a task without overwriting
|
|
its main body. Suitable for recording work sessions, decisions, or status updates over time.
|
|
|
|
## Journal
|
|
|
|
Fable Scribe runs a per-day Journal — a conversational surface where the user narrates
|
|
their day. Each day has its own conversation. The first assistant message in a day's
|
|
conversation is the **daily prep**: an LLM-generated briefing covering today's tasks,
|
|
calendar events, weather, active projects, and recent journal context. Subsequent turns
|
|
are user/assistant journaling exchanges; the LLM may emit **Moments** (small structured
|
|
extractions) via the `record_moment` tool during the conversation.
|
|
|
|
Use `fable_get_today_journal` to inspect today's prep + conversation. Use
|
|
`fable_get_journal_day` for past days. Use `fable_list_moments` to query the structured
|
|
journal extractions across days. Use `fable_trigger_journal_prep` to force-regenerate
|
|
today's prep prose.
|
|
|
|
## Admin logs
|
|
|
|
`fable_get_app_logs` requires an admin-scoped API key. Regular user keys will be rejected.
|
|
"""
|
|
|
|
mcp = FastMCP("fable", instructions=_INSTRUCTIONS)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Notes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_list_notes(
|
|
limit: int = 20,
|
|
offset: int = 0,
|
|
tag: str = "",
|
|
search_text: str = "",
|
|
) -> dict:
|
|
"""List notes (non-task documents) stored in Fable.
|
|
|
|
Optionally filter by a single tag (plain string, no # prefix) or a keyword search
|
|
against title and body. Results are ordered by last-updated descending.
|
|
|
|
Use fable_search for semantic/meaning-based lookup instead of exact keyword search.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await notes.list_notes(
|
|
client,
|
|
limit=limit,
|
|
offset=offset,
|
|
tag=tag or None,
|
|
search=search_text or None,
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_get_note(note_id: int) -> dict:
|
|
"""Fetch the full content of a single Fable note by its ID.
|
|
|
|
Returns id, title, body (markdown), tags, project_id, created_at, updated_at.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await notes.get_note(client, note_id=note_id)
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_create_note(
|
|
title: str,
|
|
body: str = "",
|
|
tags: list[str] | None = None,
|
|
project_id: int = 0,
|
|
) -> dict:
|
|
"""Create a new note in Fable.
|
|
|
|
Args:
|
|
title: Note title (required).
|
|
body: Markdown content. Supports [[wikilinks]] to other notes by title.
|
|
tags: List of plain-string tags without # prefix, e.g. ["python", "ideas"].
|
|
project_id: Associate with a project (use 0 for no project / orphan note).
|
|
|
|
Returns the created note object including its assigned id.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await notes.create_note(
|
|
client,
|
|
title=title,
|
|
body=body,
|
|
tags=tags,
|
|
project_id=project_id or None,
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_update_note(
|
|
note_id: int,
|
|
title: str = "",
|
|
body: str = "",
|
|
tags: list[str] | None = None,
|
|
project_id: int = 0,
|
|
) -> dict:
|
|
"""Update an existing Fable note. Only explicitly provided fields are changed.
|
|
|
|
Args:
|
|
note_id: ID of the note to update.
|
|
title: New title, or omit to leave unchanged.
|
|
body: New markdown body, or omit to leave unchanged.
|
|
tags: Replaces the full tag list. Pass [] to clear all tags. Omit to leave unchanged.
|
|
project_id: New project association (0 = remove from project). Omit to leave unchanged.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await notes.update_note(
|
|
client,
|
|
note_id=note_id,
|
|
title=title or None,
|
|
body=body or None,
|
|
tags=tags,
|
|
project_id=project_id or None,
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_delete_note(note_id: int) -> str:
|
|
"""Permanently delete a Fable note by ID. This cannot be undone."""
|
|
async with FableClient() as client:
|
|
await notes.delete_note(client, note_id=note_id)
|
|
return f"Note {note_id} deleted."
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tasks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_list_tasks(
|
|
limit: int = 20,
|
|
offset: int = 0,
|
|
status: str = "",
|
|
project_id: int = 0,
|
|
) -> dict:
|
|
"""List tasks in Fable.
|
|
|
|
Args:
|
|
status: Filter by status — one of: todo, in_progress, done, cancelled. Omit for all.
|
|
project_id: Filter to a specific project. Use 0 for no filter.
|
|
|
|
Results are ordered by last-updated descending.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await tasks.list_tasks(
|
|
client,
|
|
limit=limit,
|
|
offset=offset,
|
|
status=status or None,
|
|
project_id=project_id or None,
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_get_task(task_id: int) -> dict:
|
|
"""Fetch a single Fable task by ID.
|
|
|
|
Returns id, title, body, status, priority, tags, project_id, milestone_id,
|
|
parent_id, parent_title, due_date, created_at, updated_at.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await tasks.get_task(client, task_id=task_id)
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_create_task(
|
|
title: str,
|
|
body: str = "",
|
|
status: str = "todo",
|
|
priority: str = "",
|
|
project_id: int = 0,
|
|
milestone_id: int = 0,
|
|
parent_id: int = 0,
|
|
tags: list[str] | None = None,
|
|
) -> dict:
|
|
"""Create a new task in Fable.
|
|
|
|
Args:
|
|
title: Task title (required).
|
|
body: Markdown description / notes for the task.
|
|
status: Initial status — one of: todo (default), in_progress, done, cancelled.
|
|
priority: One of: low, medium, high. Omit for no priority (defaults to "none").
|
|
project_id: Associate with a project (0 = no project).
|
|
milestone_id: Place within a project milestone (0 = no milestone).
|
|
parent_id: Make this a sub-task of another task (0 = top-level).
|
|
tags: List of plain-string tags without # prefix.
|
|
|
|
Returns the created task object including its assigned id.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await tasks.create_task(
|
|
client,
|
|
title=title,
|
|
body=body,
|
|
status=status,
|
|
priority=priority or None,
|
|
project_id=project_id or None,
|
|
milestone_id=milestone_id or None,
|
|
parent_id=parent_id or None,
|
|
tags=tags,
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_update_task(
|
|
task_id: int,
|
|
title: str = "",
|
|
body: str = "",
|
|
status: str = "",
|
|
priority: str = "",
|
|
project_id: int = 0,
|
|
milestone_id: int = 0,
|
|
) -> dict:
|
|
"""Update an existing Fable task. Only explicitly provided fields are changed.
|
|
|
|
Args:
|
|
task_id: ID of the task to update.
|
|
title: New title, or omit to leave unchanged.
|
|
body: New markdown body, or omit to leave unchanged.
|
|
status: New status — one of: todo, in_progress, done, cancelled.
|
|
priority: New priority — one of: none, low, medium, high.
|
|
project_id: New project (0 = remove from project). Omit to leave unchanged.
|
|
milestone_id: New milestone (0 = remove from milestone). Omit to leave unchanged.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await tasks.update_task(
|
|
client,
|
|
task_id=task_id,
|
|
title=title or None,
|
|
body=body or None,
|
|
status=status or None,
|
|
priority=priority or None,
|
|
project_id=project_id or None,
|
|
milestone_id=milestone_id or None,
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_add_task_log(task_id: int, content: str) -> dict:
|
|
"""Append a timestamped progress log entry to a Fable task.
|
|
|
|
Use this to record work sessions, decisions, or status updates over time without
|
|
overwriting the task's main body. Each entry is stored separately and shown
|
|
chronologically in the task view.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await tasks.add_task_log(client, task_id=task_id, content=content)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Projects
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_list_projects() -> dict:
|
|
"""List all Fable projects for the current user.
|
|
|
|
Returns id, title, description, goal, status (active/archived), color,
|
|
and a short auto-generated summary for each project.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await projects.list_projects(client)
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_get_project(project_id: int) -> dict:
|
|
"""Fetch a Fable project by ID, including its milestone summary.
|
|
|
|
Returns full project fields plus a milestone_summary list with each milestone's
|
|
id, title, status, and task counts.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await projects.get_project(client, project_id=project_id)
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_create_project(
|
|
title: str,
|
|
description: str = "",
|
|
goal: str = "",
|
|
status: str = "active",
|
|
color: str = "",
|
|
) -> dict:
|
|
"""Create a new project in Fable.
|
|
|
|
Args:
|
|
title: Project name (required).
|
|
description: Short summary of what the project is.
|
|
goal: The desired outcome or definition of done for the project.
|
|
status: active (default) or archived.
|
|
color: Optional hex colour for the project card (e.g. "#6366f1").
|
|
|
|
Returns the created project object including its assigned id.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await projects.create_project(
|
|
client,
|
|
title=title,
|
|
description=description,
|
|
goal=goal or None,
|
|
status=status,
|
|
color=color or None,
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_update_project(
|
|
project_id: int,
|
|
title: str = "",
|
|
description: str = "",
|
|
goal: str = "",
|
|
status: str = "",
|
|
color: str = "",
|
|
) -> dict:
|
|
"""Update an existing Fable project. Only explicitly provided fields are changed.
|
|
|
|
Args:
|
|
project_id: ID of the project to update.
|
|
title: New title, or omit to leave unchanged.
|
|
description: New description, or omit to leave unchanged.
|
|
goal: New goal/definition-of-done, or omit to leave unchanged.
|
|
status: New status — active or archived.
|
|
color: New hex colour, or omit to leave unchanged.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await projects.update_project(
|
|
client,
|
|
project_id=project_id,
|
|
title=title or None,
|
|
description=description or None,
|
|
goal=goal or None,
|
|
status=status or None,
|
|
color=color or None,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Milestones
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_list_milestones(project_id: int) -> dict:
|
|
"""List milestones for a Fable project, ordered by order_index.
|
|
|
|
Returns id, title, description, status (active/done), order_index, and task counts.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await milestones.list_milestones(client, project_id=project_id)
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_create_milestone(
|
|
project_id: int,
|
|
title: str,
|
|
description: str = "",
|
|
status: str = "active",
|
|
) -> dict:
|
|
"""Create a milestone within a Fable project.
|
|
|
|
Args:
|
|
project_id: The project this milestone belongs to (required).
|
|
title: Milestone name (required).
|
|
description: Optional description of what this milestone covers.
|
|
status: active (default) or done.
|
|
|
|
Returns the created milestone including its assigned id.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await milestones.create_milestone(
|
|
client,
|
|
project_id=project_id,
|
|
title=title,
|
|
description=description,
|
|
status=status,
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_update_milestone(
|
|
project_id: int,
|
|
milestone_id: int,
|
|
title: str = "",
|
|
description: str = "",
|
|
status: str = "",
|
|
order_index: int = -1,
|
|
) -> dict:
|
|
"""Update a Fable milestone. Only explicitly provided fields are changed.
|
|
|
|
Args:
|
|
project_id: Project the milestone belongs to.
|
|
milestone_id: ID of the milestone to update.
|
|
title: New title, or omit to leave unchanged.
|
|
description: New description, or omit to leave unchanged.
|
|
status: New status — active or done.
|
|
order_index: New display position (0-based). Use -1 to leave unchanged.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await milestones.update_milestone(
|
|
client,
|
|
project_id=project_id,
|
|
milestone_id=milestone_id,
|
|
title=title or None,
|
|
description=description or None,
|
|
status=status or None,
|
|
order_index=order_index if order_index >= 0 else None,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Search
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_search(
|
|
q: str,
|
|
content_type: str = "all",
|
|
limit: int = 10,
|
|
) -> dict:
|
|
"""Semantic search over Fable notes and tasks using embedding similarity.
|
|
|
|
Finds content by meaning rather than exact keywords. Use this to discover
|
|
relevant records when you don't know the exact title or tags.
|
|
|
|
Args:
|
|
q: Natural-language query string.
|
|
content_type: "note", "task", or "all" (default).
|
|
limit: Maximum number of results (default 10).
|
|
|
|
Returns results ordered by cosine similarity, each with id, title, body snippet, and tags.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await search.search(client, q=q, content_type=content_type, limit=limit)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Chat
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_list_conversations(limit: int = 20, offset: int = 0) -> dict:
|
|
"""List chat conversations stored in Fable, ordered by last activity.
|
|
|
|
Returns id, title, message_count, created_at, updated_at for each conversation.
|
|
Use the id with fable_send_message to continue a specific conversation.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await chat.list_conversations(client, limit=limit, offset=offset)
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_send_message(
|
|
message: str,
|
|
conversation_id: str = "",
|
|
think: bool = False,
|
|
) -> dict:
|
|
"""Send a natural-language message to Fable's built-in LLM and receive the full response.
|
|
|
|
Fable handles tool use, RAG context injection, and conversation history internally.
|
|
The LLM can create/update notes and tasks, search, manage projects, and more — all
|
|
driven by natural language without you needing to call individual tools.
|
|
|
|
Use this when:
|
|
- The request is conversational or exploratory
|
|
- You want Fable's RAG to automatically surface relevant notes as context
|
|
- The task is complex enough to benefit from Fable's internal reasoning
|
|
|
|
Use the direct CRUD tools (fable_create_note, etc.) instead when you need
|
|
structured data back or are performing bulk/programmatic operations.
|
|
|
|
Args:
|
|
message: The user message to send.
|
|
conversation_id: Continue an existing conversation by passing its id.
|
|
Omit to start a new conversation.
|
|
think: Enable extended reasoning mode for complex multi-step requests.
|
|
|
|
Returns conversation_id (for follow-up messages), the assistant response text,
|
|
and a list of any tool_call events that fired during generation.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await chat.send_message(
|
|
client,
|
|
message=message,
|
|
conversation_id=conversation_id or None,
|
|
think=think,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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-scoped API key.
|
|
|
|
Args:
|
|
category: Log category — "error" (default), "audit", "usage", or "generation".
|
|
limit: Maximum number of log entries to return.
|
|
search: Optional keyword filter matched against action, endpoint, username, details.
|
|
|
|
Returns a list of log entries ordered by most recent first.
|
|
Regular user API keys will receive a 403 — only admin keys are accepted.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await admin.get_app_logs(
|
|
client,
|
|
category=category,
|
|
limit=limit,
|
|
search=search or None,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Journal — daily prep, day payloads, moments
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_get_today_journal() -> dict:
|
|
"""Fetch today's Journal day payload.
|
|
|
|
Creates today's journal conversation and generates the daily prep
|
|
message if neither exists yet. Returns:
|
|
{
|
|
"day_date": "YYYY-MM-DD",
|
|
"conversation": { id, title, conversation_type, day_date, ... },
|
|
"messages": [ ... ordered list of messages ... ]
|
|
}
|
|
|
|
The first assistant message is the daily prep — a conversational
|
|
opener generated by the LLM from gathered tasks/events/weather/
|
|
projects/recent moments/open threads. Its ``msg_metadata.sections``
|
|
carries the underlying structured data for inspection.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await journal.get_today_journal(client)
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_get_journal_day(iso_date: str) -> dict:
|
|
"""Fetch a specific day's Journal payload by ISO date.
|
|
|
|
Args:
|
|
iso_date: YYYY-MM-DD format date.
|
|
|
|
Returns the same shape as fable_get_today_journal. If no journal
|
|
exists for that day, ``conversation`` and ``messages`` will be
|
|
null/empty respectively.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await journal.get_journal_day(client, iso_date=iso_date)
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_list_journal_days() -> dict:
|
|
"""List dates that have journal content for the current user, newest first.
|
|
|
|
Returns ``{"days": ["YYYY-MM-DD", ...]}``. Use these dates to query
|
|
specific days via ``fable_get_journal_day``.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await journal.list_journal_days(client)
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_trigger_journal_prep(iso_date: str = "") -> dict:
|
|
"""Force-regenerate the daily prep prose for today (or a specific day).
|
|
|
|
The prep is the first assistant message in a day's journal — a
|
|
conversational LLM-generated briefing built from tasks/events/weather/
|
|
projects/recent moments/open threads. Use this to iterate on the prep
|
|
prompt or refresh after data changes.
|
|
|
|
Args:
|
|
iso_date: Optional YYYY-MM-DD. If empty, regenerates today.
|
|
|
|
Returns ``{"ok": true, "message_id": ...}``.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await journal.trigger_journal_prep(
|
|
client, iso_date=iso_date or None,
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_get_journal_config() -> dict:
|
|
"""Fetch the user's journal config.
|
|
|
|
Includes prep schedule (prep_enabled / prep_hour / prep_minute),
|
|
day rollover hour, phase boundaries, and any locations / temp_unit
|
|
used for the prep's weather section.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await journal.get_journal_config(client)
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_list_moments(
|
|
query: str = "",
|
|
person_id: int = 0,
|
|
place_id: int = 0,
|
|
tag: str = "",
|
|
date_from: str = "",
|
|
date_to: str = "",
|
|
pinned_only: bool = False,
|
|
limit: int = 50,
|
|
) -> dict:
|
|
"""Search/list journal Moments — small structured extractions the LLM emits during journaling.
|
|
|
|
All filters are optional and combinable. Without a query, returns
|
|
moments ordered by occurred_at DESC. With a query, returns
|
|
semantically-ranked moments above the similarity threshold.
|
|
|
|
Args:
|
|
query: Optional semantic query string.
|
|
person_id: Filter to moments mentioning this person (0 = no filter).
|
|
place_id: Filter to moments mentioning this place (0 = no filter).
|
|
tag: Filter to moments with this tag.
|
|
date_from: ISO YYYY-MM-DD lower bound (inclusive).
|
|
date_to: ISO YYYY-MM-DD upper bound (inclusive).
|
|
pinned_only: If True, only return pinned moments.
|
|
limit: Max results, default 50.
|
|
|
|
Returns ``{"moments": [...]}`` where each moment has id, day_date,
|
|
occurred_at, content, raw_excerpt, tags, people, places, task_ids,
|
|
note_ids, pinned, and (when query set) score.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await journal.list_moments(
|
|
client,
|
|
query=query or None,
|
|
person_id=person_id if person_id else None,
|
|
place_id=place_id if place_id else None,
|
|
tag=tag or None,
|
|
date_from=date_from or None,
|
|
date_to=date_to or None,
|
|
pinned_only=pinned_only,
|
|
limit=limit,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Generic conversation access
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
async def fable_get_conversation(conversation_id: int) -> dict:
|
|
"""Fetch any conversation (chat or journal) 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 inspecting journal preps, chat history, or verifying
|
|
that a tool actually ran with the expected arguments.
|
|
"""
|
|
async with FableClient() as client:
|
|
return await journal.get_conversation(
|
|
client, conversation_id=conversation_id,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def main() -> None:
|
|
# Validate env vars at startup — raises ValueError with a clear message if missing.
|
|
FableClient()
|
|
mcp.run(transport="stdio")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|