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.
The MCP endpoint now exposes the notebook as read-only resources rather than empty inventories. Phase 2 of #645: clients can read the full-notebook JSON metadata, a single-file Markdown dump of every note, or any individual note by its source path — all without touching the filesystem, served straight from Emanote's live in-memory model.
Three URIs under the
emanote://scheme:emanote://export/metadata(JSON, reusesrenderJSONExport),emanote://export/content(Markdown, reusesrenderContentExport), andemanote://note/{path}for individual notes.resources/listreturns the two static exports plus one entry per note;resources/templates/listadvertises the{path}template for RFC 6570-aware clients. The architecturally interesting bit is that Ema'sDynamicis push-only — there's no pull-side API to snapshot the current model. Phase 2 adds a newEmanoteConfigfield,_emanoteConfigOnModelUpdate :: Maybe (Model -> IO ())— a subscription hook that Emanote'sEmaSite.siteInputcalls on the initial model and every subsequent update.Emanote.runowns anIORefand wireswriteIORef ref . Justas the hook, so MCP handlers canreadIORefthe live snapshot. Storage is the caller's concern; the config boundary stays abstract, which keeps the phase-4 refactor (fanout to per-subscriber queues) local totapModel.Try it locally
Then point Claude Code or Codex at
http://localhost:8079/mcp(seedocs/guide/mcp.mdfor client setup).