Skip to content

Add MCP server for live Emanote model #645

Description

@srid

Summary

Add an MCP server to Emanote that runs in the same long-lived process as the live server, so MCP clients can query Emanote's in-memory notebook model directly instead of going through the filesystem.

Uses the Haskell dpella/mcp library for protocol and transport support, while keeping Emanote's own model/query/export code as the source of truth.

Goals

  • Run live server and MCP server in the same process
  • Reuse the existing reactive Emanote model
  • Expose notebook data primarily as MCP resources
  • Add a small set of read-only tools for structured queries
  • Ship this incrementally, one PR per phase

Non-goals

  • No write/edit tools in the first iteration
  • No separate notebook parser for MCP
  • No filesystem-first MCP layer that bypasses Emanote's model
  • No authentication today — the server is intended for local use bound to a loopback port

Design direction

  • Ema remains responsible for keeping the in-memory model live and for normal site rendering.
  • MCP sits beside Ema, sharing the same current model snapshot.
  • MCP does not go through SiteRoute/Ema.Asset; it reads from Emanote's model/query/export layer directly.
  • Prefer MCP resources for notebook reads; use tools only for query-shaped operations.
  • Keep resources/list independent of notebook size. Anything that scales linearly in N inflates context on every poll. Per-item addressing goes through RFC 6570 URI templates advertised on resources/templates/list.

Phases

Phase 1: Same-process MCP transport + minimal handshake — ✅ Shipped in #648

Scope:

  • Add dpella/mcp dependency
  • Add CLI/config plumbing to enable MCP HTTP transport alongside live server (emanote run --mcp-port PORT)
  • Start MCP endpoint in the same long-running process as the live server
  • Implement minimum lifecycle/RPC surface (initialize, empty resources/list, empty tools/list, …)

Phase 2: Notebook-backed resources — ✅ Shipped in #649

Scope:

  • Wire MCP handlers to the live model via Ema.Dynamic.currentValue (returns a non-blocking IO Model reader; no shared IORef, no startup race)
  • Expose notebook state as MCP resources:
    • emanote://export/metadata — JSON metadata for every note (titles, source paths, parent routes, resolved links); also the discovery surface for note paths
    • emanote://note/{path} — per-note read via an RFC 6570 URI template advertised on resources/templates/list
  • Distinguish error codes: 400 for unrecognized URIs, 404 for catalog misses
  • resources/list returns only the metadata export (O(1) in notebook size); per-note addressing goes through the template

Deliberately not shipped:

  • Per-note entries in resources/list (would be O(N) per call)
  • emanote://export/content (bundled-content blob — same information available via metadata + per-note reads, blows context budgets, the CLI emanote export --format=content still produces this artifact for human/script use)

The emanote://note/{path} resource was later removed in phase 3 (#746). See the refinement note below.

Phase 3: Query tools — ✅ Shipped in #745, refined in #746

Scope:

  • Three read-only MCP tools so the model can navigate the notebook without dumping enumerations into context:
    • find_notes — case-insensitive substring search over note titles and source paths
    • get_backlinks — wrap Model.Graph.modelLookupBacklinks
    • resolve_wikilink — wrap the existing Model.Link.Resolve path
  • Narrow input/output schemas; each tool result carries the note's notebook-relative path + title
  • Reuse the same IO Model reader phase 2 plumbed

Refinement (#746):

  • Drop the emanote://note/{path} per-note read resource introduced in phase 2 and the derived uri field on tool results. MCP clients (Claude Code, Codex, opencode, …) already have direct filesystem read tools, so serving note bodies over MCP just duplicates that capability and adds a round trip per read.
  • The notebook-relative path in every tool result and in emanote://export/metadata (each note's filePath) is the canonical handle — clients hand it to their own Read tool. resources/read emanote://note/{path} now returns 400 Unrecognized resource URI.
  • Catalog dispatch collapses to a single [(NotebookResource, Model -> ResourceBody)] table; listResources and the new readResource lookup both derive from it, so adding a future resource is one tuple. Structural collapse cross-validated by hickey + lowy lenses.

Acceptance:

  • Clients can ask structured questions without pulling the metadata export blob
  • Tool results are derived from the live model (same snapshot semantics as the metadata resource)
  • A 422-note notebook is tractable through search instead of enumeration
  • Wire surface is one resource + three tools; path is the canonical handle from MCP to the filesystem

Phase 4: Live update support

Scope (smaller than originally outlined — phase 2's currentValue seam already eliminates the startup race the original plan was going to close):

  • Wire MCP resource subscriptions (resources/subscribe, resources/unsubscribe, notifications/resources/updated)
  • Emit notifications/resources/list_changed when the catalog changes (e.g. a new tag index resource appears)
  • Cover at least the metadata export

Acceptance:

  • A subscribed client observes updates after a notebook change without re-polling
  • capabilities.resources.subscribe and capabilities.resources.listChanged flip from Nothing to enabled
  • Polling clients are unaffected (subscription is opt-in)

Phase 5: Optional prompt surfaces

Scope (only if the resource + tool surface proves insufficient):

  • Add prompts/list / prompts/get for prompts like:
    • summarize note + backlinks
    • answer using notebook only
    • compare two notes

Acceptance:

  • Prompts are optional sugar on top of the resource/tool API, not a replacement

Settled open questions

  • CLI shape: emanote run --mcp-port PORT (phase 1).
  • Model sharing: Ema.Dynamic.currentValue returns (IO Model, wrapped Dynamic); the reader is passed as a function argument to MCP.run, no shared IORef or new EmanoteConfig field (phase 2 commit ff3b6407).
  • Per-note bodies over MCP: not served — clients read source files via their own filesystem tools, MCP hands them the relative path (phase 3, MCP: drop per-note resource; clients read files themselves #746).
  • Authentication: deferred — server is loopback-only, no auth today. Re-open if/when a remote-transport use case appears.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions