MCP server, phase 2: notebook-backed resources - #649
Conversation
Phase 2 of #645: the MCP server now serves the notebook's live model as three read-only resource URIs: - emanote://export/metadata — JSON metadata (reuses renderJSONExport) - emanote://export/content — single-file Markdown dump (reuses renderContentExport) - emanote://note/{path} — one note by its source path EmanoteConfig gains an optional IORef (Maybe Model); when --mcp-port is set, Emanote.run populates it and Emanote.tapModelRef mirrors every Ema update into it so the MCP handlers can snapshot the current model without driving Ema's render loop. Clients arriving before the first model is built receive a JSON-RPC 503 and retry. resources/list returns the two static exports plus one entry per note; resources/templates/list advertises emanote://note/{path} for clients that consume RFC 6570 templates.
Hickey F1: the instructions string hand-rolled the per-note URI prefix
('emanote://note/') as a literal, making it a fourth site of the same
fact already captured by 'noteUriPrefix'. Introduce 'noteUriTemplate'
(referenced by both 'instructions' and 'noteTemplate') and the example
URI reuses 'noteUriPrefix' directly.
Lowy F1 + Hickey F3: EmanoteConfig previously held '_emanoteConfigLiveModelRef :: Maybe (IORef (Maybe Model))', which leaked the implementation (a particular storage primitive) into a config boundary and named what the field contained rather than why it existed. Replace with '_emanoteConfigOnModelUpdate :: Maybe (Model -> IO ())' — a subscription callback. Storage is the caller's concern: 'Emanote.run' owns the IORef and hands MCP.run a reader for it, while the config exposes only the update hook. This also makes the phase-4 refactor (fanout to per-subscriber queues) local: 'tapModel' changes from writing to one ref to dispatching through a bus, without rippling through EmanoteConfig.
Lowy F3: the 'Nothing' branch in 'withModel' is not a real error state but an artifact of starting the MCP server and Ema concurrently via 'race_'. Phase 4 should deliver a pre-bind await so clients never see it. Capture the rationale in the doc-comment so future maintainers know this is temporary.
/simplify quality pass flagged 3-level nesting in the note-path branch of readResource (T.stripPrefix, parseNoteRoute, lookupNotesByRoute, readNoteContent). Pull the branch into its own helper and collapse the first two Maybe layers with (>>=). Top-level readResource becomes a flat four-way dispatch; the nested cases move to readNoteResource's body where they're local to one concern.
Hickey/Lowy Analysis
Hickey rationaleThe only real complecting find was F1: the F2 (merging F3 converged with Lowy F1 and was fixed there. Lowy rationaleF1 ( F2 and F3 are both deferred to phase 4 with explicit doc-comments naming the expected refactor. F4 becomes a non-concern once F1 is fixed: the |
|
| Step | Status | Duration | Verification |
|---|---|---|---|
| sync | ✓ | 0s | git fetch ok; forge=github; noGit=false |
| research | ✓ | 7m 40s | Architecture: IORef Model tapped in siteInput, shared with MCP handlers. Reuse renderJSONExport/renderContentExport/readNoteContent/generateNoteHeader. URI shape emanote://export/{metadata,content}, emanote://note/{path}. MCP types confirmed in dpella/mcp source. |
| branch | ✓ | 10s | On feat/mcp-server-phase2 |
| implement | ✓ | 5m 14s | New module Emanote.MCP with 3 resource URIs and a template; IORef (Maybe Model) wired via tapModelRef in Emanote.hs; EmanoteConfig extended with _emanoteConfigLiveModelRef |
| check | ✓ | 1m 26s | cabal build all clean; smoke-tested all 3 resource URIs + 404 error paths + template listing via curl |
| docs | ✓ | 1m 35s | Updated docs/guide/mcp.md with resources table + URI template note; refreshed CHANGELOG entry to reflect phase 2; nix build .#docs passes |
| fmt | ✓ | 49s | fourmolu + hlint + cabal-fmt + nixpkgs-fmt all pass; build still clean after formatting |
| commit | ✓ | 18s | commit 33bde3b on feat/mcp-server-phase2 pushed to origin |
| hickey+lowy | ✓ | 7m 4s | Hickey F1 fixed (6268bdd); Lowy F1 + Hickey F3 combined into observer refactor (73409d8); Lowy F3 documented (7ba1a29); Hickey F2 no-op (separation is clarifying); Lowy F2/F4 addressed inline / contingent. Smoke-tested post-refactor. |
| police | ✓ | 5m 22s | Rules: clean. Fact-check: clean. Elegance: 1 fix (extract readNoteResource, commit 6449a82). 4 findings deferred with rationale (per-request caching is premature at phase 2 scale; DuplicateRecordFields noise is codebase-wide style). |
| test | — | 0s | setup: user skipped |
| create-pr | ✓ | 1m 21s | Draft PR #649 opened with narrative body; hickey/lowy ledger + rationale posted as comment |
| ci | ✓ | 1m 32s | vira ci FullBuild: both platforms signed off on 6449a82; HEAD matches |
| done | ✓ | 0s | Phase 2 complete; draft PR #649 open |
| Total | 35m 16s |
Slowest step: research (7m 40s)
Optimization suggestions
- Research (7m 40s) dominated again despite the pre-loaded
dpella-mcpskill. The skill saved the MCP-protocol rediscovery, but the main research spend was on Emanote's own layers —EmanoteConfig,Dynamic, export module surfaces. A companionemanote-architectureskill summarizing the renderer/model/exporter seam would pay off starting phase 3. - Hickey+lowy (7m 4s) is the second-most-expensive step — roughly two minutes of sub-agent wall-clock plus the commit-per-finding cadence. For small phases, consider batching the observer-refactor + doc-comments into two commits rather than three to trim ~30s; diminishing returns beyond that.
- Implement + check + fmt together are 7m 29s — this looked right-sized for the scope (one new module, one config field, one Dynamic tap). No suggestion.
- Phase 3 (query tools) should skip research entirely if the
dpella-mcpskill is pre-loaded and include "tools live under the same IORef (Maybe Model) as resources" in the invocation — the MCP tool surface is shaped likeemanote/src/Emanote/MCP.hs:readResourcedispatch, just with different inputs.
Workflow completed at 2026-04-23.
'just run' now passes --mcp-port=8079 so the MCP HTTP endpoint comes up alongside the live server. apm.yml declares the server under dependencies.mcp per the APM MCP spec, so Claude/Codex pick it up during development.
Claude Code picks up emanote via 'just run' on http://localhost:8079/mcp.
824721a to
7a6d6ca
Compare
With srid/ema#179, emanote calls its own siteInput, applies currentValue to tee the Dynamic, and hands the wrapped Dynamic to runSiteWithInput — racing the MCP server against Ema's live loop at the Emanote.run level. This retires three layers of phase-2 scaffolding: - EmanoteConfig loses _emanoteConfigOnLiveModel (the publish-callback field that let siteInput hand the reader back to Emanote.run). - Emanote.MCP loses LiveModel / newLiveModel / publishLiveModel (the blocking-MVar handle introduced to bridge the publish/consume race). - Emanote's EmaSite instance's siteInput collapses back to its pre-MCP body — a plain emanoteSiteInput <&> modelUpdateCachedFields. Net: -39 lines of plumbing. MCP and Ema compose via race_ at the call site, with currentValue sitting exactly where it belongs. Ema input still pinned to feat/run-site-with-input pending #179 merge.
**`runSiteWith` now composes out of two steps** — `siteInput` (build the `Dynamic`) and `runSiteWithInput` (consume it). The same class of problems that motivated `currentValue` (#177) — teeing the `Dynamic` for an out-of-band consumer — needs a seam between those two steps. Before this PR, `runSiteWith` sealed them together; the only escape was plumbing a callback through the user's `SiteArg`, which either conflated config with transport wiring or (worse) bypassed the user's `siteInput` entirely. _The user's `siteInput` still runs._ Callers that need the tee just invoke `siteInput` themselves, compose (`currentValue`, `<*>`, whatever), and pass the result to `runSiteWithInput`. Callers that don't need it keep using `runSiteWith` exactly as before — its body is now a two-liner: ```haskell runSiteWith cfg arg = flip runLoggerLoggingT (getLogger …) $ do dyn <- siteInput @r (CLI.action cli) arg runSiteWithInput cfg dyn ``` `runSiteWithInput` is polymorphic in `m` (`MonadUnliftIO m, MonadLoggerIO m, MonadFail m`), which also lets the live-server race stay in one logger context — `UnliftIO.Async.race_` replaces `Control.Concurrent.Async.race_`, and both branches run in `m` directly. Net: the existing `liftIO $ race_` / `runLoggingT logger` scaffolding collapses, and `async` drops out of the direct deps (it's still pulled in transitively via `unliftio`). > Fully additive. `runSite`, `runSite_`, `runSiteWith` are unchanged in signature and behaviour. `runSiteWithInput` is a new export. Motivated by [srid/emanote#649](srid/emanote#649 MCP server: with this PR, emanote drops its `tapModel` helper, the `_emanoteConfigOnLiveModel` callback field, and the publish-through-config plumbing entirely — MCP and Ema compose via `race_` at the call site. ### Try it locally ```sh nix build github:srid/ema/feat/run-site-with-input ```
srid/ema#179 landed; drop the branch pin.
Both MCP and non-MCP paths share siteInput + runSiteWithInput now; currentValue only taps the Dynamic when MCP is enabled. Also link PR #649 in the MCP changelog entry. Addresses PR review feedback.
Splits the two volatility axes that were tangled in Emanote.MCP: - Emanote.View.Export.Catalog (new) — protocol-agnostic catalog: ResourceKind (MetadataJson | ContentMarkdown | Note FilePath), listResources, readResource. Knows nothing about MCP/URIs. - Emanote.MCP — MCP protocol surface only: URI constants, capability declarations, handshake text, uri↔kind translation, toMcpResource adapter, thin handlers. Prepares Phase 3 query tools / future non-MCP surfaces to reuse the catalog verbs without re-deriving route enumeration and header composition. Zero behavior change at the MCP wire.
MCP is today's only consumer; the catalog shares MCP's change cadence (new resource kinds arrive with new MCP features). Moving under Emanote.MCP.Catalog reflects that. The module stays MCP- independent in its types — if a second surface ever appears, it promotes up with one rename.
Emanote.MCP becomes an umbrella module re-exporting 'run'. Work lives in submodules decomposed by concern: - Emanote.MCP.Uri — wire-contract URI constants + ResourceKind↔URI translation (pure, no MCP type deps) - Emanote.MCP.Handlers — request handlers + Catalog→MCP wire-type adapters (toMcpResource, textResult, noteTemplate) - Emanote.MCP.Server — Warp startup + server identity / capabilities / instructions Zero behavior change at the MCP wire.
# Conflicts: # emanote/CHANGELOG.md # flake.lock
|
Any ETA on this landing? I run emanote on this thingy: |
Phase 2 ships no MCP tools; advertising the capability with an empty list is a false interface contract.
Mirror the contract Server.hs describes: the IO Model reader is non-blocking only because currentValue seeded the IORef before returning, and the wrapped Dynamic must run on the other race_ arm to keep emitting updates past the initial snapshot.
|
@dpwiz Yea, I'll try to get this merged. Note that |
…Types The MCPHandlerState=() and MCPHandlerUser=() instances are package-level choices, not handler logic. Live in their own module so a future second transport can import them without depending on Handlers.
MIME type is determined by kind, not stored per-resource. Drops the duplicated resourceMime / resourceBodyMime fields in favour of a single kindMime function; both Handlers callsites derive from kind.
templateFor is exhaustive on ResourceKind constructors, so adding a new kind forces an explicit decision about whether it advertises a URI template. Replaces the hand-maintained [noteTemplate] list.
readResource now returns Either CatalogError ResourceBody. Unrecognized URIs (didn't match the emanote:// scheme) get RPC error 400; resources that exist in the scheme but aren't in the catalog get 404. Clients can tell a malformed request from a missing resource.
…tions instructions iterates Catalog.staticResources and Handlers.templateFor, formatting each into a bullet. Removes the parallel hand-coded list of URI/description lines that previously duplicated text already held in the catalog and template definitions.
Note {} signals 'ignore all fields' more explicitly than Note _ for a
single-arity constructor, and survives any future field additions.
Collapse 'do { x <- m; pure (f x) }' into 'f <$> m'.
Cons-then-append (':' + '<>') reads more awkwardly than a plain list
literal followed by '++' for each section. Same semantics, single
left-to-right concat.
Hickey/Lowy Analysis (polish pass)Second structural review, run cold against the post-refactor branch (
Hickey rationaleFour findings landed, all narrow. #1 — Lowy rationaleThree findings landed, two declined. #7 added an exhaustiveness gate over #5 (strip MCP presentation fields from #6 (move CodaCross-validation was skipped: the seven surviving findings are local refinements (one renamed function, one moved module, one comment, one capability flip, one template-derivation, one error-shape change, one instructions generator) with no structural overlap, so the usual risk of one fix creating a problem the other lens would flag doesn't apply here. |
|
| Step | Status | Duration | Verification |
|---|---|---|---|
| hickey+lowy | ✓ | 29m 12s | 7 fixes applied as separate commits (4 hickey, 3 lowy); 2 no-ops with rationale (Lowy A: fields are general resource metadata; Lowy B: ResourceKind is notebook vocabulary). cabal build all clean after each commit. |
| police | ✓ | 17m 34s | All 3 passes clean: rules clean, fact-check clean, elegance produced 3 small refactors committed individually (1d0e6e6 record wildcard, 2f50b1a point-free, 46cdb2c uniform list construction). |
| test | ✓ | 3m 14s | cabal test all: 125 examples, 0 failures. Live MCP smoke test confirmed all 7 refactors behave correctly: bogus URI → 400, missing note → 404, capabilities advertises resources only. |
| create-pr | ✓ | 1m 25s | PR body refreshed (Types module, error-semantics, instructions-generator, coverage-gap section). Polish-pass Hickey/Lowy ledger posted as a second comment. |
| ci | ✓ | 2m 47s | vira ci FullBuild signed off on 46cdb2c for both platforms. e2e-live 288/288, e2e-static 199 passed (89 skipped), e2e-morph 288/288. |
| evidence | — | 11s | Skipped — no UI impact (MCP protocol internals only). |
| done | ✓ | 7s | All 6 polish steps recorded; vira ci + e2e green on HEAD 46cdb2c. |
| Total | 54m 31s |
Slowest step: hickey+lowy (29m 12s)
Optimization suggestions
- hickey+lowy ran cold against the full diff again, even though a first review had already produced commits
6268bddd–7ba1a295. Most of the 9 findings this round were new (post-refactor architecture), but a delta-review would still have cut wall-clock. For polish runs against PRs with a prior ledger, pipe the previous ledger comment into the reviewer prompt and scope the diff to commits since that ledger. - The 7-fixes × (edit + fmt + build + push) cycle in hickey+lowy accounts for roughly half the step's wall-clock. The per-finding commit cadence is the right artifact; the cost is the build-per-commit. Running
cabal buildonce at the end of the batch (after all fixes apply cleanly) and then splitting into individual commits viagit add -pwould shave ~2 min, at the cost of any one fix that breaks the build going undetected until the batch finishes. just fmtinvoked the project's wrapped fourmolu (2-space) while/code-police's elegance pass invoked an unwrapped fourmolu (4-space), requiring a re-run ofjust fmtto settle. Worth either pinning/code-policeto calljust fmtinstead of barefourmolu, or documenting the wrapped binary's path so the skill picks it up.- Smoke-testing MCP runtime live (cabal-run + curl recipe in /tmp) filled the no-unit-tests gap inline. Codifying that as a
just mcp-smokerecipe would let future polish runs assert wire-level correctness without an ad-hoc script.
Workflow completed at 2026-05-25.
resources/list now returns only the two static exports
(emanote://export/metadata, emanote://export/content). Per-note
addressing is still fully supported through the emanote://note/{path}
URI template (advertised via resources/templates/list); clients
construct URIs from the template and call resources/read directly.
Removes the linear-in-notebook-size response that previously inflated
every resources/list call. A 422-note notebook now returns 2 entries
instead of 424.
Trade-off: clients whose only resource UI is fuzzy-search over
resources/list (the @-mention pickers in Claude Code and opencode) no
longer see individual notes there. Model-driven reads (Codex's
read_mcp_resource tool; Claude Code's auto-provided list/read tools)
are unaffected — the model can construct any note URI from the
template. Discovery is via emanote://export/metadata, which carries
every note's source path. Phase 3's find_notes tool will make this an
explicit lookup.
In docs/guide/mcp.md: - New "Algorithmic complexity" subsection lists per-MCP-method cost in notebook size N and relations R (initialize, resources/list, resources/templates/list, all three resources/read variants). - Move the Codex client config and the curl sanity check out of the Resources section into Client setup, where they structurally belong. Closes a pre-existing nesting glitch made more visible by recent edits. In Catalog.hs and Handlers.hs: - Haddock complexity notes on listResources, readResource (per-kind), kindMime, templateFor, and the module-level handlers note. Future readers see the cost without re-deriving it.
Phase 2 originally exposed three resources: metadata, single-file content, and per-note via template. The bundled-content blob is now removed: the same information is reachable via metadata (for discovery) + per-note reads (for content), and the blob is actively counterproductive for the only audience MCP serves — an agent loop where the resource size has to fit a context window. The CLI 'emanote export --format=content' is untouched; that surface is for human/script use where a single-file artifact is the point. Net wire change: ResourceKind loses the ContentMarkdown constructor. 'emanote://export/content' now returns 400 'Unrecognized resource URI'. 'resources/list' returns a single entry (the metadata export). Breaking only against earlier phase-2 commits on this branch; phase 2 hasn't shipped a release yet.

Phase 2 of #645: the MCP endpoint now exposes the notebook as read-only resources instead of an empty inventory. Clients can read full-notebook JSON metadata (which also serves as the path-discovery surface) and any individual note by its source path — all served straight from Emanote's live in-memory model, no filesystem round-trip.
Two surfaces under the
emanote://scheme:emanote://export/metadata(JSON, reusesrenderJSONExport) — also the discovery surface for note pathsemanote://note/{path}— per-note reads via the URI template advertised throughresources/templates/listresources/listreturns only the metadata export. Per-note entries are intentionally not enumerated — that would scale linearly with notebook size and inflate every poll. The bundled-content blob (emanote://export/content) is also intentionally not exposed: the same information is reachable via metadata + per-note reads, and the blob would blow context budgets on any non-trivial notebook. The CLIemanote export --format=contentstill produces that artifact for human/script use. Full discussion in the changelog anddocs/guide/mcp.md.Model-driven reads (Codex's
read_mcp_resource, Claude Code's auto-provided list/read tools) construct per-note URIs from the template and work unchanged;@-mention pickers that only fuzzy-searchresources/list(Claude Code, opencode) won't surface individual notes there, so users reference notes by asking the model instead. Phase 3 will add afind_notestool to make discovery explicit.Error responses distinguish unrecognized URIs (400) from URIs that parse but reference nothing in the catalog (404).
The architecturally interesting bit is how MCP handlers get the current model snapshot. Ema's
Dynamicis push-only — there's no pull-side API. The branch resolves this with a single call intoEma.Dynamic.currentValue, which returns both a non-blocking snapshot reader and a re-wrappedDynamicthat still pushes downstream:The
IO Modelreader is passed directly as a function argument toMCP.run :: Int -> Bool -> IO Model -> IO (). There is no sharedIORef, no newEmanoteConfigfield, and no startup race:currentValueblocks on theDynamic's initial emission before returning, so by the time Warp binds the MCP port the first model snapshot is already published.The MCP module is split into focused submodules:
Emanote.MCP.Uriemanote://…) — the breaking-change surfaceEmanote.MCP.CatalogNotebookResource/ResourceBodyprojection, MIMEEmanote.MCP.HandlersEmanote.MCP.ServerEmanote.MCP.TypesMCPHandlerState/MCPHandlerUserinstancesThe server-side
instructionsstring is generated by iteratingCatalog.staticResourcesandHandlers.templateFor, so a new resource kind shows up in the LLM-facing documentation without a parallel update. The resource template list is similarly derived:templateFor :: ResourceKind -> Maybe ResourceTemplateis exhaustive on the kind, so adding a constructor forces a yes/no decision on whether it gets a URI template. Per-resource MIME is derived fromkindMime :: ResourceKind -> Text— one source of truth, no per-record duplication.Per-route algorithmic complexity
With N = note count and R = total resolved relations:
initializeresources/listresources/templates/listresources/read emanote://export/metadataresources/read emanote://note/{path}Reads are uncached; phase 4's subscriptions replace polling with push for clients that opt in.
Try it locally
Then point Claude Code or Codex at
http://localhost:8079/mcp— seedocs/guide/mcp.mdfor client setup.Phase notes
The first Hickey/Lowy ledger comment below documents an earlier observer-hook architecture (
_emanoteConfigOnModelUpdate) which a follow-up refactor (ff3b6407,e442737c) replaced with thecurrentValue-based seam above. A second Hickey/Lowy pass on the current diff (separate comment) records this PR's structural refinements. Phase 4 (subscriptions) no longer needs to close the documented 503 startup race — that race is gone withcurrentValue's ordering guarantee.Known coverage gap
No MCP unit or integration tests exist yet — runtime correctness is verified by a manual smoke test against an emanote process and the docs page's
curlrecipe. A dedicated test-suite for the MCP transport should land alongside phase 3 (tools), where it gives more leverage.