8000 feat(notes): find, read and write notes over MCP by adeelahmad · Pull Request #137 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,8 @@ Todo:
- [x] AI title and summary refinement (ai.refine_notes, on by default, can be switched off)
- [x] Costs of refinement and filing to the activity ledger (`note_page:id` and its namespace; shown on the page)
- [x] Assistant tools: find, read, write and update notes (at once, no approval: notes are its notebook)
- [ ] MCP tools to read notes; page history so the assistant's changes can be undone
- [x] MCP tools to find, read and write notes (find_notes, read_note, write_note)
- [ ] Page history so the assistant's changes can be undone
- [x] Self-organising, first step: notes nobody filed are filed in PARA by a decision model (or the LLM), with a
suggestion when it isn't sure (ai.organise_notes)
- [ ] Self-organising, next: link notes to entities and topics, nest them under their project or area
Expand Down
7 changes: 5 additions & 2 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ front of the web app also needs `TRUST_PROXY_HEADERS=true` ([Authentication](aut
| `list_topics` | each namespace's vocabulary of topics ([Topics](topics.md)), by any label, top topics or the narrower ones of a topic |
| `get_topic` | one topic: its labels, definition, broader, narrower and related topics, and the recordings about it, with links |
| `suggest_topic` | suggest that recordings are about a topic; it waits for someone to accept it. Needs a write-scope token and editor access |
| `find_notes` | [notes](notes.md) by words in their title, summary or text, or by where they're filed (PARA), with links |
| `read_note` | a note's Markdown with its links and backlinks, by id or as the page of a thing (`recording:12`) |
| `write_note` | write a note or a thing's page, marked as written by an assistant. Needs a write-scope token and editor access |
| `propose_graph_change` | ask for two entities to be merged or linked; it waits in Proposed changes unless `apply` makes it at once (it can be undone). Needs a write-scope token and editor access |

Results are JSON (as `structuredContent`, and the same as text). Links open the recording's page in the web app at
Expand All @@ -63,8 +66,8 @@ argument, something it can't read) come back as a tool result with `isError`, so
person's to read answers "not found", as the API does. `search` and `fetch` follow the shape ChatGPT's connectors
expect (`results` with `id`, `title`, `url`; `fetch` by that `id`).

Every tool reads, except `suggest_topic`, which adds suggestions people accept or dismiss, and `propose_graph_change`,
which records an undoable change (proposed by default).
Every tool reads, except `suggest_topic`, which adds suggestions people accept or dismiss, `propose_graph_change`,
which records an undoable change (proposed by default), and `write_note`, which adds a note.
Importing, editing and curating stay in the web app and the API.

## Protocol
Expand Down
5 changes: 3 additions & 2 deletions docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ assistant write. The assistant is the main writer and organiser; people can do e

Status: **built**: pages (free notes in a tree, a page per recording, entity, topic, collection or speaker), @ and #
links with backlinks (# links topics), the tree in the left navigation, the page view with the BlockSuite editor, the
model keeping titles and summaries up to date, and assistant tools to find, read, write and update notes.
**Planned**: MCP tools, page history, linking notes to entities and topics and nesting them under their project or
model keeping titles and summaries up to date, assistant tools to find, read, write and update notes, and MCP tools to
find, read and write them ([MCP](mcp.md)).
**Planned**: page history, linking notes to entities and topics and nesting them under their project or
area on their own, attachments on encrypted object storage, pages in the graph.

## A page
Expand Down
117 changes: 116 additions & 1 deletion fastapi_backend/app/api/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@
from app.api.v1.routes import entities as entity_routes
from app.api.v1.routes import graph as graph_routes
from app.api.v1.routes import namespaces as namespace_routes
from app.api.v1.routes import notebook as note_routes
from app.api.v1.routes import recordings as recording_routes
from app.api.v1.routes import search as search_routes
from app.api.v1.routes import topics as topic_routes
from app.domain import analyze, graph_history, library, rdf, render, store
from app.domain import analyze, graph_history, library, notebook, rdf, render, store
from app.domain import entities as ents
from app.domain import speakers as spk
from app.domain import topics as topicmod
Expand Down Expand Up @@ -1145,3 +1146,117 @@ def propose_graph_change(ctx: Context, kind: str, a: str, b: str, reason: str |
raise ToolError("this token is read-only: ask for a token with the write scope to propose changes")
body = graph_routes.ChangeAsk(kind=kind, a=a, b=b, reason=reason, apply=apply)
return graph_routes.propose_graph_change(ctx.request, body, ctx.user, ctx.acl, ctx.db)


# ---------- notes (docs/notes.md) ----------
def _note_url(ctx: Context, pid: int) -> str:
return f"{ctx.web}/notes/{pid}"


@tool(
"find_notes",
"Find notes",
"Notes in the namespaces you can read: free notes and the pages of recordings, entities, topics, collections and "
"speakers, found by words in their title, one-line summary or text (title matches first, then the latest). Each "
"has its id, one-line summary, where it is filed (PARA: project, area, resource, archive), what it is the page of, "
"and a url.",
Arg("query", "string", "words to look for", max_length=200),
Arg("namespace", "string", "only this namespace"),
Arg("place", "string", "only notes filed here", enum=notebook.PLACES),
Arg("limit", "integer", "how many", default=20, minimum=1, maximum=100),
)
def find_notes(ctx: Context, query: str | None, namespace: str | None, place: str | None, limit: int) -> dict[str, Any]:
spaces = [ctx.acl.namespace(namespace)] if namespace else ctx.acl.spaces()
names = store.space_names(ctx.db)
total, hits = notebook.find(ctx.db, spaces, query, place, limit)
return {
"total": total,
"notes": [
store.clean(
{
"note_id": r["id"],
"title": r["title"],
"summary": r.get("summary"),
"place": r.get("place"),
"page_of": r.get("about"),
"namespace": names.get(r["space"]),
"updated": r.get("updated_at"),
"url": _note_url(ctx, r["id"]),
}
)
for r in hits
],
}


@tool(
"read_note",
"Read a note",
"A note's Markdown, with what it links to and the notes linking to it: by note_id, or the page of a thing "
'(about, like "recording:12", "entity:5" or "topic:9"). Links in the Markdown are written @[label](kind:id) and, for '
"topics, #[label](topic:id).",
Arg("note_id", "integer", "the note (from find_notes)"),
Arg("about", "string", 'the thing whose page to read, like "recording:12"', max_length=60),
)
def read_note(ctx: Context, note_id: int | None, about: str | None) -> dict[str, Any]:
if note_id is None and not about:
raise ToolError("give note_id or about")
if note_id is not None:
p = note_routes.get_page(note_id, ctx.user, ctx.acl, ctx.db)
else:
kind, _, key = str(about).partition(":")
if not key.isdigit():
raise ToolError('about is a thing like "recording:12"')
p = note_routes.page_about(kind, int(key), ctx.user, ctx.acl, ctx.db)
if p.id is None:
return {"page_of": about, "note": None, "title": p.title, "linked_from": [b.title for b in p.backlinks] or None}
return store.clean(
{
"note_id": p.id,
"title": p.title,
"summary": p.summary,
"date": p.date,
"place": p.place,
"page_of": p.about,
"parent_id": p.parent,
"namespace": p.namespace,
"written_by": p.author,
"body": _cut(p.body, FETCH_CHARS),
"links": [{"target": x.target, "name": x.name} for x in p.links] or None,
"linked_from": [{"note_id": b.page, "title": b.title} for b in p.backlinks] or None,
"url": _note_url(ctx, p.id),
}
)


@tool(
"write_note",
"Write a note",
"Write a new note in a namespace, or the page of a thing (about; each thing has one). Give it a specific title, a "
"one-line summary of what it holds, Markdown text linking with @[label](recording:12), @[label](entity:5), "
"@[label](page:3) and #[label](topic:9), and where it is filed. It is marked as written by an assistant. Needs a "
"token with the write scope and editor access.",
Arg("namespace", "string", "the namespace", required=True),
Arg("title", "string", "the title", required=True, max_length=200),
Arg("body", "string", "the text, in Markdown", required=True, max_length=200_000),
Arg("summary", "string", "one line on what it holds", max_length=300),
Arg("place", "string", "where it is filed", enum=notebook.PLACES),
Arg("parent_id", "integer", "put it inside this note"),
Arg("about", "string", 'make it the page of this thing, like "entity:5"', max_length=60),
writes=True,
)
def write_note(
ctx: Context,
namespace: str,
title: str,
body: str,
summary: str | None,
place: str | None,
parent_id: int | None,
about: str | None,
) -> dict[str, Any]:
if not ctx.user.can_write:
raise ToolError("this token is read-only: ask for a token with the write scope to write notes")
sid = ctx.acl.namespace(namespace, "editor")
pid = notebook.create(ctx.db, sid, ctx.user.id, title, body, summary, None, place, parent_id, about, author="assistant")
return {"note_id": pid, "url": _note_url(ctx, pid)}
17 changes: 3 additions & 14 deletions fastapi_backend/app/domain/ai_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -799,18 +799,7 @@ def _space(self, namespace, edit=False):
def t_find_notes(self, query=None, namespace=None, place=None, limit=10):
spaces = [self._space(namespace)] if namespace else sorted(self.readable)
names = store.space_names(self.db)
q = " ".join(str(query or "").split()).casefold()
rows = self.db.rows(
"SELECT record::id(id) AS id, space, title, summary, place, about, updated_at, text FROM note_page WHERE space IN $s",
s=spaces,
)
hits = [
r
for r in rows
if (not place or r.get("place") == place)
and (not q or any(q in str(r.get(k) or "").casefold() for k in ("title", "summary", "text")))
]
hits.sort(key=lambda r: (q not in str(r["title"]).casefold(), r.get("updated_at") or ""), reverse=False)
total, hits = notebook.find(self.db, spaces, query, place, min(int(limit or 10), 30))
out = [
store.clean(
{
Expand All @@ -822,9 +811,9 @@ def t_find_notes(self, query=None, namespace=None, place=None, limit=10):
"namespace": names.get(r["space"]),
}
)
for r in hits[: min(int(limit or 10), 30)]
for r in hits
]
return {"total": len(hits), "notes": out}, f"Found {len(hits)} note(s)"
return {"total": total, "notes": out}, f"Found {total} note(s)"

def t_read_note(self, note_id=None, about=None):
if note_id is None and not about:
Expand Down
19 changes: 19 additions & 0 deletions fastapi_backend/app/domain/notebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,25 @@ def labels(db, targets):
return out


def find(db, spaces, query="", place=None, limit=10):
"""Pages in these namespaces whose title, summary or text has the words, those with them in the title first, then
the latest changed. Returns (how many matched, the first `limit` without their bodies)."""
q = " ".join(str(query or "").split()).casefold()
rows = db.rows(
"SELECT record::id(id) AS id, space, title, summary, place, about, updated_at, text FROM note_page WHERE space IN $s",
s=list(spaces),
)
hits = [
r
for r in rows
if (not place or r.get("place") == place)
and (not q or any(q in str(r.get(k) or "").casefold() for k in ("title", "summary", "text")))
]
hits.sort(key=lambda r: r.get("updated_at") or "", reverse=True)
hits.sort(key=lambda r: q not in str(r["title"]).casefold())
return len(hits), [{k: v for k, v in r.items() if k != "text"} for r in hits[:limit]]


def search_targets(db, sid, q="", sign="@", limit=20):
"""What a mention can link to in namespace sid, best matches first: for #, topics (by any of their labels); for @,
pages, recordings, people and other entities, collections and speakers."""
Expand Down
37 changes: 36 additions & 1 deletion fastapi_backend/tests/api/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def test_clients_are_sent_to_sign_in(client, app, env):
assert [t["name"] for t in tools] == list(mcp_tools.TOOLS)
assert all(t["inputSchema"]["type"] == "object" for t in tools)
# every tool only reads, except suggesting a topic and asking for a graph change (which need the write scope)
assert [t["name"] for t in tools if not t["annotations"]["readOnlyHint"]] == ["suggest_topic", "propose_graph_change"]
assert [t["name"] for t in tools if not t["annotations"]["readOnlyHint"]] == ["suggest_topic", "propose_graph_change", "write_note"]
assert next(t for t in tools if t["name"] == "search")["inputSchema"]["required"] == ["query"]
# API tokens and sessions work too
assert rpc(client, env["hv"], "ping")["result"] == {}
Expand Down Expand Up @@ -389,3 +389,38 @@ def test_topic_tools(client, db, env):
]
assert "list of whole numbers" in tool_error(client, he, "suggest_topic", topic_id=gene, recording_ids=["x"])
assert tool_error(client, he, "suggest_topic", topic_id=gene, recording_ids=[env["ids"][2]]) # calls isn't theirs


def test_note_tools(client, db, env):
h = env["h"] # a read-only app token for a pods viewer
a, _, call = env["ids"]
pods, calls = store.ns_id(db, "pods"), store.ns_id(db, "calls")
from app.domain import notebook

plan = notebook.create(
db, pods, None, "Capsid plan", f"Ship on Friday, see @[ep1](recording:{a}).", "Shipping capsids", place="project"
)
notebook.create(db, pods, None, "Hiring", "Two roles open.")
notebook.create(db, calls, None, "Capsid call notes", "Not for pods viewers.")
found = tool(client, h, "find_notes", query="capsid")
assert [(n["note_id"], n["summary"], n["place"]) for n in found["notes"]] == [(plan, "Shipping capsids", "project")]
assert found["notes"][0]["url"].endswith(f"/notes/{plan}")
assert tool(client, h, "find_notes", place="area")["total"] == 0
got = tool(client, h, "read_note", note_id=plan)
assert got["body"].startswith("Ship on Friday") and got["links"] == [{"target": f"recording:{a}", "name": got["links"][0]["name"]}]
assert tool(client, h, "read_note", about=f"recording:{a}") == {
"page_of": f"recording:{a}",
"note": None,
"title": tool(client, h, "read_note", about=f"recording:{a}")["title"],
"linked_from": ["Capsid plan"],
}
other = db.one("SELECT record::id(id) AS id FROM note_page WHERE space = $s", s=calls)["id"]
assert "not found" in tool_error(client, h, "read_note", note_id=other)
assert "not found" in tool_error(client, h, "read_note", about=f"recording:{call}")
assert "read-only" in tool_error(client, h, "write_note", namespace="pods", title="x", body="y")
make_user(db, "ed@x.io", "editor password 1", roles={"pods": "editor"})
he = login(client, "ed@x.io", "editor password 1")
made = tool(client, he, "write_note", namespace="pods", title="Agent notes", body="From an agent.", place="resource", parent_id=plan)
p = notebook.get(db, made["note_id"])
assert (p["author"], p["place"], p["parent"]) == ("assistant", "resource", plan)
assert tool_error(client, he, "write_note", namespace="calls", title="x", body="y")
Loading
0