Conversation
…wikilink) Add three read-only MCP tools so a client can navigate a notebook without dumping the metadata-export blob into context: * find_notes — case-insensitive substring search against note titles and source paths, with a configurable limit * get_backlinks — wraps Model.Graph.modelLookupBacklinks * resolve_wikilink — wraps Model.Link.Resolve.resolveWikiLinkMustExist, including ambiguity resolution by closest common ancestor Tools share the live IO Model reader plumbed in phase 2; no shared IORef, no caching. tools/list now flips ToolsCapability on; the dpella/mcp withToolHandlers wires both list and call. Unit tests cover each helper's success and error paths (12 cases). The docs/guide/mcp.md Tools section enumerates inputs/outputs and extends the algorithmic-complexity table. Closes phase 3 of #645.
…ModelRoute lmlSourcePath is withLmlRoute encodeRoute — a pure route-layer helper with no View or JSON-export concern. Its home was an accident of being the first consumer's module. Adding MCP.Tools as a non-View consumer made the layering inversion visible (MCP depending on View). Move it to its natural home alongside withLmlRoute / mkLMLRouteFromKnownFilePath. lmlRouteKey stays in View.Export.JSON (single-consumer, JSON-key-specific) but now derives from R.lmlSourcePath instead of redefining the body.
…dOrOrgFilePath
Catalog.hs and Tools.hs each defined identical
`mkLMLRouteFromKnownFilePath Md fp <|> mkLMLRouteFromKnownFilePath Org fp`
helpers. The coupling invariant ('both modules agree on what counts as a
recognised note path') was structural — adding a third LML format would
require two updates with nothing forcing them to agree.
Hoist into Route.ModelRoute alongside mkLMLRouteFromKnownFilePath, which
encapsulates the same volatility axis (LML-format recognition).
… latent title divergence In findNotes the note is already in scope; the previous noteMatchOf discarded it and re-derived the title via M.modelLookupTitle, doing a second IxSet lookup per match. More subtly, the predicate's match key (note._noteTitle) and the report's title (modelLookupTitle) were computed via different paths — they agreed today only accidentally. noteMatchOf now takes a Note directly. The route-only callers (getBacklinks, RRTAmbiguous in resolveWikilink) go through a new noteMatchOfRoute helper that does one lookup at the call boundary.
…dant field The uri field on NoteMatch was a derived value (noteUriPrefix <> path) stored alongside its source, with the rule enforced only inside noteMatchOf. Two consequences: * The pure result type imported wire-layer concerns (Emanote.MCP.Uri + Catalog ResourceKind) for a field every consumer could derive. * Two ways to drift: if noteUriPrefix ever changes, every site that reads NoteMatch.uri sees the stale value. Drop uri from the record and emit it from the manual ToJSON instance. Wire shape is unchanged (clients still receive uri); construction is concentrated in one place. The test asserts on the JSON payload now that the field no longer exists on the Haskell value.
Two volatility axes were braided in one module: notebook-query helpers (findNotes, getBacklinks, resolveWikilink, NoteMatch, ResolveResult) and MCP wire scaffolding (InputSchema literals, argument parsing, ToolHandler construction, toolJsonResult / toolError). Phase 2 encapsulated the analogous split for resources: pure Catalog beside MCP-typed Handlers. Tools collapsed the same seam. Mirror the established pattern. ToolCatalog now owns the pure surface (no dpella/mcp imports — verifiable from the import list); Tools is just the wire adapter on top. ToolsSpec moves to ToolCatalogSpec since the tests cover the pure helpers.
…Wikilink Right is pure for Either; using pure in do-notation keeps the function reading as monadic sequencing instead of leaking the Either constructor.
…Integer truncate (toRational n) silently floors non-integers and wraps on overflow. toBoundedInteger rejects both — fractional JSON numbers and out-of-range values come back as Nothing instead of being silently coerced into the default. The JSON schema already bounds the input, but trusting validation at the boundary is the wrong direction; validate at the consumer.
…o Nothing The empty-string guard isn't covered by the JSON schema (which only asserts "type: string"), and different MCP clients differ on whether they omit absent optional fields or send "". The guard collapses both into one absent signal so callers don't need to know about the difference.
Hickey/Lowy Analysis
Hickey rationaleThree findings, all narrow:
Lowy rationaleThree findings, all Fix-in-this-PR:
Cross-validationEach reviewer was then shown the other's findings and asked whether applying them would create a problem its lens would flag. Hickey concurred with Lowy's diagnosis on Finding C but refined the fix: rather than recompute Generated by |
EvidenceCaptured against tools/list[
{
"name": "find_notes",
"description": "Search the notebook for notes whose title or source path contains the query (case-insensitive). Returns up to `limit` matches.",
"required": [
"query"
]
},
{
"name": "get_backlinks",
"description": "List notes that backlink to the note at the given source path.",
"required": [
"path"
]
},
{
"name": "resolve_wikilink",
"description": "Resolve a wikilink (e.g. \"guide/mcp\") to a note or static file. Optionally relative to a source note for disambiguation; defaults to the notebook index.",
"required": [
"wikilink"
]
}
]find_notes — query="wikilink", limit=3{
"matches": [
{
"path": "guide/markdown/file-links.md",
"title": "File WikiLinks",
"uri": "emanote://note/guide/markdown/file-links.md"
},
{
"path": "guide/wikilinks.md",
"title": "Wiki Links",
"uri": "emanote://note/guide/wikilinks.md"
}
]
}get_backlinks — path="guide/folgezettel.md"{
"backlinks": [
{
"path": "guide/wikilinks.md",
"title": "Wiki Links",
"uri": "emanote://note/guide/wikilinks.md"
},
{
"path": "guide/html-template/uptree.md",
"title": "Uplink tree",
"uri": "emanote://note/guide/html-template/uptree.md"
},
{
"path": "guide/html-template/sidebar.md",
"title": "Sidebar",
"uri": "emanote://note/guide/html-template/sidebar.md"
},
{
"path": "guide/markdown.md",
"title": "Markdown ✍️",
"uri": "emanote://note/guide/markdown.md"
},
{
"path": "guide/folgezettel.md",
"title": "Folgezettel links",
"uri": "emanote://note/guide/folgezettel.md"
},
{
"path": "guide/folder-note.md",
"title": "Folder notes",
"uri": "emanote://note/guide/folder-note.md"
},
{
"path": "guide/html-template/breadcrumbs.md",
"title": "Breadcrumbs",
"uri": "emanote://note/guide/html-template/breadcrumbs.md"
}
]
}resolve_wikilink — wikilink="guide/mcp"{
"kind": "note",
"note": {
"path": "guide/mcp.md",
"title": "MCP server",
"uri": "emanote://note/guide/mcp.md"
},
"result": "found"
}resolve_wikilink — wikilink="no-such-note"{
"result": "missing"
}get_backlinks — bogus path → isError{
"content": [
{
"text": "Not a recognised note path: foo.invalid",
"type": "text"
}
],
"isError": true
}get_backlinks — missing required → JSON-RPC -32602{
"code": -32602,
"message": "Missing required arguments: path"
}Generated by |
|
| Step | Status | Duration | Verification |
|---|---|---|---|
| sync | ✓ | 1s | git fetch ok; forge=github; noGit=false |
| research | ✓ | 6m 32s | Mapped phase 2 MCP code, dpella/mcp tool API, and the three model functions to wrap. |
| branch | ✓ | 9s | On feature branch mcp3 at 214480b (== origin/master, no remote yet — pushed in commit step). |
| implement | ✓ | 16m 7s | Created Emanote.MCP.Tools, wired into Handlers, advertised ToolsCapability. ToolCatalogSpec covers 12 unit tests; smoke-tested against the docs notebook over JSON-RPC. |
| check | ✓ | 10s | cabal build all clean. |
| docs | ✓ | 1m 46s | docs/guide/mcp.md Tools section + extended complexity table; CHANGELOG extended. |
| fmt | ✓ | 2m 47s | pre-commit (cabal-fmt + fourmolu + hlint + nixpkgs-fmt) all green. |
| commit | ✓ | 30s | Feature commit e62efd8 pushed to origin/mcp3. |
| hickey+lowy | ✓ | 45m 55s | 3 + 3 findings (1 overlap), cross-validation refined Lowy's uri-field fix. Five Fix commits landed: 553a5c7, 476eccf, db678c3, 349240a, 971395b. |
| police | ✓ | 15m 14s | /code-police three iterations: Data.Scientific.toBoundedInteger, WHY comment, pure vs Right. Three commits: e6bac32, e712004, 13d4c3a. |
| test | ✓ | 21s | 137 unit tests pass (12 of them new in ToolCatalogSpec). |
| create-pr | ✓ | 1m 19s | Draft PR #745 with forge-pr description + hickey/lowy analysis comment. |
| ci | ✓ | 4m 37s | vira ci signed off HEAD (13d4c3a) for aarch64-darwin + x86_64-linux. e2e-static 60/60, e2e-live 79/79, e2e-morph 79/79. |
| evidence | ✓ | 3m 50s | PR comment under ## Evidence with seven JSON-RPC samples (success paths + both error paths). |
| Total | 99m 43s |
Slowest step: hickey+lowy (45m 55s, 46% of total).
Optimization suggestions
hickey+lowydominated (46% of total). Each of the five Fix findings ran its own fmt → build → test → commit → push cycle (~5 min each). When sister findings touch the same module — Hickey C (Note-passing) and Lowy C (uri derivation) both modifiedNoteMatch/noteMatchOf— consider batching them in one commit. PR history loses one entry but saves a full CI lap.- Research was 6m 32s, mostly spent reading the
dpella/mcpsource from/nix/store/.../mcp/src. The repo has adpella-mcpskill that documents the same API; invoking it instead of grep-spelunking the nix store would have cut this step roughly in half. - The hlint pass at
fmttime caught three suggestions (maybeToRight,viaNonEmpty, unused pragma) that hlint would have caught atchecktime too. Runninghlintas part of the check command (or as a pre-commit hook only) would keep the suggestions next to the change that produced them. - The background-task harness exited with code 144 several times when launching
emanote runfor smoke tests. The synchronous-launch +&+waitpattern (run inside oneBashinvocation) worked reliably; therun_in_background: truepath didn't survive the next tool call.
Workflow completed at 2026-05-25.

Three read-only MCP tools so a client can navigate the notebook structurally instead of pulling the bulk metadata export on every question. Phase 2 shipped resources; phase 3 plugs the gap noted there — "the model has to read every note's metadata just to ask 'where is X?'" — by adding tools that answer the structural question directly. The tools share the live-model snapshot semantics phase 2 plumbed (
IO Modelreader, no sharedIORef, no caching).Tools
find_notesquery(substring), optionallimit(1–100, default 20){matches: [{path, title, uri}]}— case-insensitive matches on title or source pathget_backlinkspath(e.g.guide/mcp.md){backlinks: [{path, title, uri}]}— wrapsModel.Graph.modelLookupBacklinksresolve_wikilinkwikilink(e.g.guide/mcp), optionalfromfor disambiguation{result: "found" | "missing" | "ambiguous", …}— wrapsModel.Link.Resolve.resolveWikiLinkMustExistEach
pathis a valid suffix for theemanote://note/{path}resource template phase 2 advertised — chainfind_notesintoresources/readto load full notes.Module shape
Mirrors the phase-2
Catalog/Handlerssplit — pure surface beside MCP-typed wire adapter:ToolCataloghas zerodpella/mcpimports — verifiable from the import list — so future MCP wire changes don't ripple into the query layer, and the queries are reusable for any future non-MCP surface.Structural review
/hickey+/lowyran on the feature commit and surfaced five findings, each landed as its own commit so the PR history reads as a sequence of structural refinements:553a5c76lmlSourcePathfromView.Export.JSON→Route.ModelRoute(layering: MCP shouldn't depend on View)476eccf6parseNoteRoutevia newRoute.mkLMLRouteFromMdOrOrgFilePathdb678c32NotethroughnoteMatchOf— drop re-lookup and latent title-divergence349240aaNoteMatch.uriinToJSONinstance — one place ownsuri = noteUriPrefix <> path971395b1Tools.hsinto pureToolCatalog+ wireTools/code-policeadded three elegance refinements:pureinstead ofRightindo-notation,Data.Scientific.toBoundedIntegerforreadIntArg(safer thantruncate . toRational), and a WHY comment onreadTextArgMaybe's empty-string normalisation.Try it locally
Then point Claude Code / Codex at
http://localhost:8079/mcpand call any of the three tools through the model.Closes phase 3 of #645. Phase 4 (resource subscriptions) is the next slice.
Generated by
/doon Claude Code (modelclaude-opus-4-7).