7b5a75989a
Task 2 of #582. New mcp/tools/processes.py mirrors entities.py — tools wrap notes_svc directly. get_process is the fire mechanism (returns the full prompt via resolve_process; surfaces other_matches on an ambiguous name). Registered in register_all. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
92 lines
3.6 KiB
Python
92 lines
3.6 KiB
Python
"""Stored-process MCP tools: reusable saved prompts (note_type='process').
|
|
|
|
A process is a Note whose body is a prompt the operator fires later
|
|
("run the X process"). Mirrors entities.py — the tools wrap notes_svc directly.
|
|
get_process is the fire mechanism: it returns the full prompt for Claude to run.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from fabledassistant.mcp._context import current_user_id
|
|
from fabledassistant.services import knowledge as knowledge_svc
|
|
from fabledassistant.services import notes as notes_svc
|
|
|
|
|
|
async def list_processes(q: str = "", tag: str = "", limit: int = 50) -> dict:
|
|
"""List stored processes (reusable saved prompts).
|
|
|
|
Args:
|
|
q: Free-text search across title + body (optional).
|
|
tag: Filter to a single tag (optional).
|
|
limit: Max results (1-100).
|
|
|
|
Returns {"processes": [{id, title, tags, preview}], "total": int}.
|
|
"""
|
|
uid = current_user_id()
|
|
items, total = await knowledge_svc.query_knowledge(
|
|
user_id=uid, note_type="process", tags=[tag] if tag else [],
|
|
sort="modified", q=q or None, limit=max(1, min(limit, 100)), offset=0,
|
|
)
|
|
procs = [{"id": it["id"], "title": it["title"], "tags": it.get("tags", []),
|
|
"preview": it.get("snippet", "")} for it in items]
|
|
return {"processes": procs, "total": total}
|
|
|
|
|
|
async def create_process(title: str, body: str, tags: list[str] | None = None) -> dict:
|
|
"""Create a stored process (a reusable saved prompt).
|
|
|
|
Args:
|
|
title: Process name, e.g. "Drift Audit" (required).
|
|
body: The full prompt to run later (markdown). Required.
|
|
tags: Plain-string tags, no # prefix.
|
|
"""
|
|
if not (title or "").strip() or not (body or "").strip():
|
|
raise ValueError("create_process requires a non-empty title and body")
|
|
uid = current_user_id()
|
|
note = await notes_svc.create_note(
|
|
uid, title=title.strip(), body=body, note_type="process", tags=tags,
|
|
)
|
|
return note.to_dict()
|
|
|
|
|
|
async def get_process(name_or_id: str) -> dict:
|
|
"""Fetch a stored process by name or id and return its full prompt — the
|
|
fire mechanism. The operator says "run the <name> process"; call this and
|
|
follow the returned body (including any 'clarify first' steps it contains).
|
|
|
|
Resolution: numeric id → exact (case-insensitive) title → substring. On an
|
|
ambiguous substring match, the best (most-recent) match is returned with an
|
|
`other_matches` list so you can disambiguate with the operator.
|
|
"""
|
|
uid = current_user_id()
|
|
note, candidates = await notes_svc.resolve_process(uid, name_or_id)
|
|
if note is None:
|
|
raise ValueError(f"process {name_or_id!r} not found")
|
|
out = note.to_dict()
|
|
if candidates:
|
|
out["other_matches"] = candidates
|
|
return out
|
|
|
|
|
|
async def update_process(process_id: int, title: str = "", body: str = "",
|
|
tags: list[str] | None = None) -> dict:
|
|
"""Update a stored process. Only provided fields change — empty title/body
|
|
leave that field unchanged; pass tags to replace the tag set."""
|
|
uid = current_user_id()
|
|
note = await notes_svc.get_note(uid, process_id)
|
|
if note is None or note.note_type != "process":
|
|
raise ValueError(f"process {process_id} not found")
|
|
fields: dict = {}
|
|
if title.strip():
|
|
fields["title"] = title.strip()
|
|
if body.strip():
|
|
fields["body"] = body
|
|
if tags is not None:
|
|
fields["tags"] = tags
|
|
updated = await notes_svc.update_note(uid, process_id, **fields)
|
|
return updated.to_dict()
|
|
|
|
|
|
def register(mcp) -> None:
|
|
for fn in (list_processes, create_process, get_process, update_process):
|
|
mcp.tool(name=fn.__name__)(fn)
|