Two assistants, one toolset

Product

Notez has two ways to point an AI at your notes. There is the chat inside the app, and there is the MCP server, which lets your own assistant — Claude Code, Cursor, whatever you use — read and write the same workspace.

They were meant to be two doors into one room. Last week someone asked a reasonable question: can the assistant delete a note? The answer was no on both sides, which was fine. The answer to the next question was less comfortable.

Sixteen against ten

Chat had sixteen tools. MCP had ten.

The gap was not a decision anyone made. In August we taught the in-app chat to read and write custom properties, tags, and board columns — six new tools. That work shipped to chat and stopped there. Nobody decided MCP should not have them. Nobody noticed it didn't.

Worse, the overlap was inconsistent in a way that made the two feel like different products. Chat had search_notes; MCP had list_notes, which did the same job with more filters. Chat had semantic_search; MCP called it search_similar_notes. Same capability, different name, different shape — the kind of divergence nobody plans and everybody eventually trips over.

This is the ordinary way software rots. Not a bad decision, just an unrepeated one: a change that was correct for the surface it touched, and silent about the surface it didn't.

What we did not unify

The obvious fix is one shared implementation both surfaces call. We tried that and backed out, because two differences turned out to be load-bearing.

The results have to differ. MCP answers in JSON and throws typed errors, because it talks to a program. Chat answers in prose containing links like [Budget 2024](note:a1b2…), because the app parses those back out to render the little clickable note chips under a reply. Make chat return JSON and the chips vanish.

The errors have to differ too. When chat hits a problem it returns a sentence — "oldText was not found in the note; re-read it rather than guessing" — because the model reads that sentence and corrects itself on the next turn, inside a five-round budget. MCP throws an exception and lets the client decide. Force one contract on both and one of them gets worse.

So we unified the part that should be identical — which tools exist, what they are called, what arguments they take — and left the presentation per-surface. The guard is a test that builds the real MCP tool list, compares it against the chat declarations, and fails the build on any difference. There was exactly one allowed exception, spelled out in the test with its reason: moving a card between board columns stayed in-app, because a board looked like a visual arrangement an assistant on the other side of an API could not see. That exception is gone: today MCP moves cards between columns too, and the only tools MCP has that chat lacks are the two that upload files and, over sign-in, the one that lists your workspaces.

A comment saying "keep these in sync" would have been free. It would also have been ignored, the way the last one was.

Then we tried to add "move"

Deleting, pinning, and creating-already-nested were straightforward. Moving a note under another note looked equally straightforward — the app has had a Move under… menu item for months, so the hard part was surely done.

It was not done.

Notez stores the note tree as a closure table: not just "B's parent is A", but a row for every ancestor-descendant pair, with the distance between them. It makes "show me every note under this one, at any depth" a single indexed query instead of a recursive walk.

The move code rewrote those rows for the note being moved. Only for the note being moved.

So with A > B > C, moving B under D gave you this:

  • B's ancestors: D. Correct.

  • C's ancestors: A and B. Wrong. A is nowhere near C any more, and D is missing entirely.

C's breadcrumb read Tree A › Tree B › Tree C while B actually lived under D. The bug had been reachable from the app's own menu the whole time. It just needed someone to move a note that already had children — and to then look at one of those children.

We would have shipped a tool that let an assistant do that a hundred times a minute.

The test we almost did not write

The fix reads the subtree, deletes only the rows that leave it, and re-attaches every descendant to the new ancestors. The unit tests passed. They mock the database, so what they really confirm is that the code does what its author expected.

The end-to-end test — a real browser, a real database, move a note that has a child, walk down to the grandchild and read its breadcrumb — failed on the first run. Against the fix. Which was the point of writing it.

The cause was a detail nobody would guess and no mock would reveal: a note created at the top level has no self-row in the closure table. Every other note has a row saying "I am my own ancestor at distance zero"; a plain top-level note doesn't, because only the code path that attaches a parent writes one. So "select everything under this note" returned the children and not the note itself — and the delete step, told to clear links leaving the subtree, cheerfully cut the note's link to its own children.

The result: the new parent ended up with no children at all, and the grandchild's only ancestor was a note two levels above it.

An earlier manual check had passed. It passed by luck — the note we happened to test with had been re-parented by the old code, which always wrote a self row, so it had one.

That is the whole argument for tests that touch the real thing. A mock encodes what you believe. A database encodes what is true. When the two disagree, you want to find out from a test rather than from someone whose notes have quietly rearranged themselves.

What you get

Both surfaces now have the same eighteen tools. Your assistant can file a note under another one, pin it, put it in the Bin — and tell you it took four sub-notes with it, so you know the size of what just happened before you decide whether to undo it.

Two smaller fixes rode along. Listing notes over MCP used to return binned notes mixed in with live ones unless you explicitly excluded them, contradicting its own documentation. And asking chat "what have I been working on?" used to sort by creation date, which answered a question about this week with notes from March.

Nothing here is a feature anyone will screenshot. It is the version of the product that does what its own help pages already claimed it did — which, at a certain point in a project's life, is the more useful kind of release.

Continue reading