Skip to content

MCP server, phase 3: query tools (find_notes, backlinks, wikilink) - #745

Merged
srid merged 9 commits into
masterfrom
mcp3
May 26, 2026
Merged

MCP server, phase 3: query tools (find_notes, backlinks, wikilink)#745
srid merged 9 commits into
masterfrom
mcp3

Conversation

@srid

@srid srid commented May 25, 2026

Copy link
Copy Markdown
Owner

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 Model reader, no shared IORef, no caching).

Tools

Tool Inputs Returns
find_notes query (substring), optional limit (1–100, default 20) {matches: [{path, title, uri}]} — case-insensitive matches on title or source path
get_backlinks path (e.g. guide/mcp.md) {backlinks: [{path, title, uri}]} — wraps Model.Graph.modelLookupBacklinks
resolve_wikilink wikilink (e.g. guide/mcp), optional from for disambiguation {result: "found" | "missing" | "ambiguous", …} — wraps Model.Link.Resolve.resolveWikiLinkMustExist

Each path is a valid suffix for the emanote://note/{path} resource template phase 2 advertised — chain find_notes into resources/read to load full notes.

Module shape

Mirrors the phase-2 Catalog/Handlers split — pure surface beside MCP-typed wire adapter:

ToolCatalog (pure)  ─▶  Tools (wire)  ─▶  Handlers  ─▶  dpella/mcp
NoteMatch, queries      ToolHandler[]      withTool…       runtime

ToolCatalog has zero dpella/mcp imports — 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 + /lowy ran 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:

Commit Finding
553a5c76 Hoist lmlSourcePath from View.Export.JSONRoute.ModelRoute (layering: MCP shouldn't depend on View)
476eccf6 Dedup parseNoteRoute via new Route.mkLMLRouteFromMdOrOrgFilePath
db678c32 Pass Note through noteMatchOf — drop re-lookup and latent title-divergence
349240aa Derive NoteMatch.uri in ToJSON instance — one place owns uri = noteUriPrefix <> path
971395b1 Split Tools.hs into pure ToolCatalog + wire Tools

/code-police added three elegance refinements: pure instead of Right in do-notation, Data.Scientific.toBoundedInteger for readIntArg (safer than truncate . toRational), and a WHY comment on readTextArgMaybe's empty-string normalisation.

Try it locally

nix run github:srid/emanote/mcp3 -- -L docs run --mcp-port 8079

Then point Claude Code / Codex at http://localhost:8079/mcp and call any of the three tools through the model.

Closes phase 3 of #645. Phase 4 (resource subscriptions) is the next slice.

Generated by /do on Claude Code (model claude-opus-4-7).

srid added 9 commits May 25, 2026 16:52
…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.
@srid

srid commented May 25, 2026

Copy link
Copy Markdown
Owner Author

Hickey/Lowy Analysis

# Lens Finding Disposition
1 Hickey lmlSourcePath placed in View.Export.JSON — layering miss Fixed in this PR (553a5c76)
2 Hickey/Lowy parseNoteRoute duplicated verbatim across two modules Fixed in this PR (476eccf6)
3 Hickey noteMatchOf discards in-scope Note, forces re-lookup with latent title divergence Fixed in this PR (db678c32)
4 Lowy → Hickey cross-validation NoteMatch.uri bakes URI-scheme volatility into a pure result type Fixed in this PR (349240aa) — refined to derive uri in the ToJSON instance rather than scatter the concatenation to wire callers
5 Lowy Tools.hs braids notebook-query and MCP-wire volatility axes Fixed in this PR (971395b1)

Hickey rationale

Three findings, all narrow:

  • Finding A — Duplicated parseNoteRoute. Byte-identical helper in Catalog.hs and Tools.hs. Coupling invariant ("both 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 match.
  • Finding B — lmlSourcePath placement. withLmlRoute encodeRoute is a pure route-layer helper with no View or JSON-export concern. Its home was an accident of being the first consumer's module. Phase 3 made the layering inversion visible by adding MCP as a non-View consumer.
  • Finding C — noteMatchOf re-lookup. findNotes already held the Note in scope; the helper discarded it, passed the route, and re-derived the title via modelLookupTitle (another IxSet lookup). Worse, the match-key (note._noteTitle) and report-title (modelLookupTitle) were computed via different paths — they agreed today only accidentally.

Lowy rationale

Three findings, all Fix-in-this-PR:

  • Finding A — Tools.hs conflates two volatility axes. Pure query functions (findNotes, getBacklinks, resolveWikilink, NoteMatch, ResolveResult) lived alongside MCP wire scaffolding (InputSchema literals, argument parsing, ToolHandler construction). Phase 2 encapsulated the analogous split for resources via Catalog/Handlers; phase 3 collapsed it.
  • Finding B — parseNoteRoute dup. Same as Hickey A; hoist into Route.ModelRoute where mkLMLRouteFromKnownFilePath already lives (same volatility axis: LML-format recognition).
  • Finding C — NoteMatch.uri bakes URI-scheme volatility into a pure result type. Catalog.NotebookResource doesn't carry URIs — Handlers.toMcpResource assigns them at the wire boundary. Phase 3 inverted this by storing uri = kindToUri ... inside the pure record.

Cross-validation

Each 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 kindToUri at each wire handler (which scatters the concatenation across three callers), drop the uri field entirely and derive it inside a hand-written ToJSON instance — one place owns the rule uri = noteUriPrefix <> path. Lowy returned no cross-validation findings against Hickey's three.

Generated by /do on Claude Code (model claude-opus-4-7).

@srid

srid commented May 25, 2026

Copy link
Copy Markdown
Owner Author

Evidence

Captured against docs/ (the user-docs notebook) via the bundled MCP HTTP transport. Verifies every advertised tool returns the expected wire shape end-to-end, including the two error paths (text-level isError: true for bad input, JSON-RPC -32602 for missing required arguments).

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 /do on Claude Code (model claude-opus-4-7).

@srid

srid commented May 25, 2026

Copy link
Copy Markdown
Owner Author

/do results

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+lowy dominated (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 modified NoteMatch/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/mcp source from /nix/store/.../mcp/src. The repo has a dpella-mcp skill 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 fmt time caught three suggestions (maybeToRight, viaNonEmpty, unused pragma) that hlint would have caught at check time too. Running hlint as 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 run for smoke tests. The synchronous-launch + & + wait pattern (run inside one Bash invocation) worked reliably; the run_in_background: true path didn't survive the next tool call.

Workflow completed at 2026-05-25.

@srid

srid commented May 25, 2026

Copy link
Copy Markdown
Owner Author
image

@srid
srid marked this pull request as ready for review May 26, 2026 01:14
@srid
srid merged commit 775e9e8 into master May 26, 2026
6 checks passed
@srid
srid deleted the mcp3 branch May 26, 2026 11:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant