"""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 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)