You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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
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):
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).
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/mcplibrary for protocol and transport support, while keeping Emanote's own model/query/export code as the source of truth.Goals
Non-goals
Design direction
SiteRoute/Ema.Asset; it reads from Emanote's model/query/export layer directly.resources/listindependent of notebook size. Anything that scales linearly in N inflates context on every poll. Per-item addressing goes through RFC 6570 URI templates advertised onresources/templates/list.Phases
Phase 1: Same-process MCP transport + minimal handshake — ✅ Shipped in #648
Scope:
dpella/mcpdependencyemanote run --mcp-port PORT)initialize, emptyresources/list, emptytools/list, …)Phase 2: Notebook-backed resources — ✅ Shipped in #649
Scope:
Ema.Dynamic.currentValue(returns a non-blockingIO Modelreader; no sharedIORef, no startup race)emanote://export/metadata— JSON metadata for every note (titles, source paths, parent routes, resolved links); also the discovery surface for note pathsemanote://note/{path}— per-note read via an RFC 6570 URI template advertised onresources/templates/listresources/listreturns only the metadata export (O(1) in notebook size); per-note addressing goes through the templateDeliberately not shipped:
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 CLIemanote export --format=contentstill produces this artifact for human/script use)Phase 3: Query tools — ✅ Shipped in #745, refined in #746
Scope:
find_notes— case-insensitive substring search over note titles and source pathsget_backlinks— wrapModel.Graph.modelLookupBacklinksresolve_wikilink— wrap the existingModel.Link.Resolvepathpath+titleIO Modelreader phase 2 plumbedRefinement (#746):
emanote://note/{path}per-note read resource introduced in phase 2 and the derivedurifield 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.pathin every tool result and inemanote://export/metadata(each note'sfilePath) is the canonical handle — clients hand it to their ownReadtool.resources/read emanote://note/{path}now returns400 Unrecognized resource URI.[(NotebookResource, Model -> ResourceBody)]table;listResourcesand the newreadResourcelookup both derive from it, so adding a future resource is one tuple. Structural collapse cross-validated by hickey + lowy lenses.Acceptance:
pathis the canonical handle from MCP to the filesystemPhase 4: Live update support
Scope (smaller than originally outlined — phase 2's
currentValueseam already eliminates the startup race the original plan was going to close):resources/subscribe,resources/unsubscribe,notifications/resources/updated)notifications/resources/list_changedwhen the catalog changes (e.g. a new tag index resource appears)Acceptance:
capabilities.resources.subscribeandcapabilities.resources.listChangedflip fromNothingto enabledPhase 5: Optional prompt surfaces
Scope (only if the resource + tool surface proves insufficient):
prompts/list/prompts/getfor prompts like:Acceptance:
Settled open questions
emanote run --mcp-port PORT(phase 1).Ema.Dynamic.currentValuereturns(IO Model, wrapped Dynamic); the reader is passed as a function argument toMCP.run, no sharedIORefor newEmanoteConfigfield (phase 2 commitff3b6407).path(phase 3, MCP: drop per-note resource; clients read files themselves #746).