feat(mcp): typed-entity tools (person/place/list)
Nine tools — list/create/update for each of person, place, list.
Get and delete reuse fable_get_note / fable_delete_note (typed
entities share the Note model).
Lists: the wrappers accept an `items: list[str]` for ergonomics and
translate to the {text, checked} dict shape that
services/knowledge.py and KnowledgeView.vue expect. items=[] clears;
items=None leaves unchanged.
Updates do an explicit get → merge → update round trip so updating
one typed field doesn't clobber the others stored alongside it in
entity_meta (which is a single JSONB column).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
|
||||
from `mcp.server.build_mcp_server`.
|
||||
"""
|
||||
from fabledassistant.mcp.tools import (
|
||||
events, milestones, notes, projects, recent, search, tags, tasks,
|
||||
entities, events, milestones, notes, projects, recent, search, tags, tasks,
|
||||
)
|
||||
|
||||
|
||||
@@ -19,3 +19,4 @@ def register_all(mcp) -> None:
|
||||
events.register(mcp)
|
||||
tags.register(mcp)
|
||||
recent.register(mcp)
|
||||
entities.register(mcp)
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Typed-entity MCP tools: person, place, list.
|
||||
|
||||
These are notes with a non-'note' note_type and type-specific JSON metadata
|
||||
stored in the entity_meta column. Three tools per type — list, create, update.
|
||||
For get and delete, use fable_get_note / fable_delete_note (typed entities
|
||||
share the Note model).
|
||||
|
||||
The wrappers translate typed-field kwargs into the entity_meta dict shape that
|
||||
KnowledgeView.vue and services/knowledge.py expect.
|
||||
|
||||
Lists: a list entity stores its items in entity_meta["list_items"] as a list
|
||||
of {text, checked} dicts. The create/update tools take a simpler `items` list
|
||||
of plain strings for ergonomics; checked-state is reset to False on each call.
|
||||
"""
|
||||
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_by_type(note_type: str, q: str, tag: str, limit: int) -> dict:
|
||||
"""Common list query for a typed entity."""
|
||||
uid = current_user_id()
|
||||
items, total = await knowledge_svc.query_knowledge(
|
||||
user_id=uid,
|
||||
note_type=note_type,
|
||||
tags=[tag] if tag else [],
|
||||
sort="modified",
|
||||
q=q or None,
|
||||
limit=max(1, min(limit, 100)),
|
||||
offset=0,
|
||||
)
|
||||
# Map "items" key to a type-specific key for caller clarity.
|
||||
plural = {"person": "persons", "place": "places", "list": "lists"}[note_type]
|
||||
return {plural: items, "total": total}
|
||||
|
||||
|
||||
async def _create_entity(note_type: str, name: str, entity_meta: dict,
|
||||
tags: list[str] | None = None) -> dict:
|
||||
"""Create a typed note with the given metadata."""
|
||||
uid = current_user_id()
|
||||
note = await notes_svc.create_note(
|
||||
uid,
|
||||
title=name,
|
||||
note_type=note_type,
|
||||
entity_meta=entity_meta or None,
|
||||
tags=tags,
|
||||
)
|
||||
return note.to_dict()
|
||||
|
||||
|
||||
async def _update_entity(entity_id: int, note_type: str, name: str,
|
||||
meta_updates: dict) -> dict:
|
||||
"""Merge updates into entity_meta and (optionally) update the title."""
|
||||
uid = current_user_id()
|
||||
note = await notes_svc.get_note(uid, entity_id)
|
||||
if note is None or note.note_type != note_type:
|
||||
raise ValueError(f"{note_type} {entity_id} not found")
|
||||
new_meta = dict(note.entity_meta or {})
|
||||
new_meta.update(meta_updates)
|
||||
fields: dict = {"entity_meta": new_meta}
|
||||
if name:
|
||||
fields["title"] = name
|
||||
updated = await notes_svc.update_note(uid, entity_id, **fields)
|
||||
return updated.to_dict()
|
||||
|
||||
|
||||
# ─── Person ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_PERSON_FIELDS = ("relationship", "email", "phone", "birthday",
|
||||
"organization", "address")
|
||||
|
||||
|
||||
async def fable_list_persons(q: str = "", tag: str = "", limit: int = 25) -> dict:
|
||||
"""List people in the user's knowledge base.
|
||||
|
||||
Args:
|
||||
q: Free-text search across name + body (optional).
|
||||
tag: Filter to a single tag (optional).
|
||||
limit: Max results (1-100).
|
||||
"""
|
||||
return await _list_by_type("person", q, tag, limit)
|
||||
|
||||
|
||||
async def fable_create_person(
|
||||
name: str,
|
||||
relationship: str = "",
|
||||
email: str = "",
|
||||
phone: str = "",
|
||||
birthday: str = "",
|
||||
organization: str = "",
|
||||
address: str = "",
|
||||
tags: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Create a person in the user's knowledge base.
|
||||
|
||||
Args:
|
||||
name: Person's name (required).
|
||||
relationship: How the user knows them (e.g. "colleague", "friend").
|
||||
email / phone / birthday (YYYY-MM-DD) / organization / address: optional.
|
||||
tags: Plain-string tags, no # prefix.
|
||||
"""
|
||||
meta = {f: v for f, v in (
|
||||
("relationship", relationship), ("email", email), ("phone", phone),
|
||||
("birthday", birthday), ("organization", organization),
|
||||
("address", address),
|
||||
) if v}
|
||||
return await _create_entity("person", name, meta, tags)
|
||||
|
||||
|
||||
async def fable_update_person(
|
||||
person_id: int,
|
||||
name: str = "",
|
||||
relationship: str = "",
|
||||
email: str = "",
|
||||
phone: str = "",
|
||||
birthday: str = "",
|
||||
organization: str = "",
|
||||
address: str = "",
|
||||
) -> dict:
|
||||
"""Update a person. Only explicitly provided fields are changed.
|
||||
|
||||
To clear a field, pass an explicit space character (this preserves the
|
||||
fable-mcp empty-string-means-omit convention).
|
||||
"""
|
||||
meta_updates = {f: v for f, v in (
|
||||
("relationship", relationship), ("email", email), ("phone", phone),
|
||||
("birthday", birthday), ("organization", organization),
|
||||
("address", address),
|
||||
) if v}
|
||||
return await _update_entity(person_id, "person", name, meta_updates)
|
||||
|
||||
|
||||
# ─── Place ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def fable_list_places(q: str = "", tag: str = "", limit: int = 25) -> dict:
|
||||
"""List places (cafes, offices, addresses) in the user's knowledge base."""
|
||||
return await _list_by_type("place", q, tag, limit)
|
||||
|
||||
|
||||
async def fable_create_place(
|
||||
name: str,
|
||||
address: str = "",
|
||||
phone: str = "",
|
||||
hours: str = "",
|
||||
website: str = "",
|
||||
category: str = "",
|
||||
tags: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Create a place in the user's knowledge base.
|
||||
|
||||
Args:
|
||||
name: Place name (required).
|
||||
address / phone / hours / website / category: optional.
|
||||
tags: Plain-string tags, no # prefix.
|
||||
"""
|
||||
meta = {f: v for f, v in (
|
||||
("address", address), ("phone", phone), ("hours", hours),
|
||||
("website", website), ("category", category),
|
||||
) if v}
|
||||
return await _create_entity("place", name, meta, tags)
|
||||
|
||||
|
||||
async def fable_update_place(
|
||||
place_id: int,
|
||||
name: str = "",
|
||||
address: str = "",
|
||||
phone: str = "",
|
||||
hours: str = "",
|
||||
website: str = "",
|
||||
category: str = "",
|
||||
) -> dict:
|
||||
"""Update a place. Only explicitly provided fields are changed."""
|
||||
meta_updates = {f: v for f, v in (
|
||||
("address", address), ("phone", phone), ("hours", hours),
|
||||
("website", website), ("category", category),
|
||||
) if v}
|
||||
return await _update_entity(place_id, "place", name, meta_updates)
|
||||
|
||||
|
||||
# ─── List ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def fable_list_lists(q: str = "", tag: str = "", limit: int = 25) -> dict:
|
||||
"""List checklists in the user's knowledge base."""
|
||||
return await _list_by_type("list", q, tag, limit)
|
||||
|
||||
|
||||
async def fable_create_list(
|
||||
name: str,
|
||||
category: str = "",
|
||||
items: list[str] | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Create a checklist (a list-type entity).
|
||||
|
||||
Args:
|
||||
name: List name (required).
|
||||
category: Optional category label (e.g. "shopping", "packing").
|
||||
items: Initial item texts. All items start unchecked.
|
||||
tags: Plain-string tags, no # prefix.
|
||||
"""
|
||||
meta: dict = {}
|
||||
if category:
|
||||
meta["category"] = category
|
||||
if items:
|
||||
meta["list_items"] = [{"text": t, "checked": False} for t in items]
|
||||
return await _create_entity("list", name, meta, tags)
|
||||
|
||||
|
||||
async def fable_update_list(
|
||||
list_id: int,
|
||||
name: str = "",
|
||||
category: str = "",
|
||||
items: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Update a checklist.
|
||||
|
||||
Args:
|
||||
list_id: ID of the list to update.
|
||||
name: New title (optional).
|
||||
category: New category (optional).
|
||||
items: REPLACES the entire item set with these texts (all reset to
|
||||
unchecked). Omit (None) to leave items unchanged. Pass an empty
|
||||
list to clear all items.
|
||||
"""
|
||||
meta_updates: dict = {}
|
||||
if category:
|
||||
meta_updates["category"] = category
|
||||
if items is not None:
|
||||
meta_updates["list_items"] = [
|
||||
{"text": t, "checked": False} for t in items
|
||||
]
|
||||
return await _update_entity(list_id, "list", name, meta_updates)
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
fable_list_persons, fable_create_person, fable_update_person,
|
||||
fable_list_places, fable_create_place, fable_update_place,
|
||||
fable_list_lists, fable_create_list, fable_update_list,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
Reference in New Issue
Block a user