Skip to content

MCP server, phase 2: notebook-backed resources#649

Draft
srid wants to merge 13 commits intomasterfrom
feat/mcp-server-phase2
Draft

MCP server, phase 2: notebook-backed resources#649
srid wants to merge 13 commits intomasterfrom
feat/mcp-server-phase2

Conversation

@srid
Copy link
Copy Markdown
Owner

@srid srid commented Apr 23, 2026

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, reuses renderJSONExport), emanote://export/content (Markdown, reuses renderContentExport), and emanote://note/{path} for individual notes. resources/list returns the two static exports plus one entry per note; resources/templates/list advertises the {path} template for RFC 6570-aware clients. The architecturally interesting bit is that Ema's Dynamic is push-only — there's no pull-side API to snapshot the current model. Phase 2 adds a new EmanoteConfig field, _emanoteConfigOnModelUpdate :: Maybe (Model -> IO ()) — a subscription hook that Emanote's EmaSite.siteInput calls on the initial model and every subsequent update. Emanote.run owns an IORef and wires writeIORef ref . Just as the hook, so MCP handlers can readIORef the 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 to tapModel.

One known gap: a client arriving in the window between Warp's socket bind and Ema's first model snapshot will hit a JSON-RPC 503 "model not yet loaded", not a real resource read. Phase 4 should close this by deferring MCP.run's bind until the initial model is published. Documented in withModel.

Try it locally

nix run github:srid/emanote/feat/mcp-server-phase2 -- run --port 9010 --mcp-port 8079

Then point Claude Code or Codex at http://localhost:8079/mcp (see docs/guide/mcp.md for client setup).

srid added 5 commits April 23, 2026 16:38
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.
@srid
Copy link
Copy Markdown
Owner Author

srid commented Apr 23, 2026

Hickey/Lowy Analysis

# Lens Finding Disposition
1 Hickey URI scheme fragmented — instructions hand-rolled prefix literal Fixed (6268bdd)
2 Hickey Static vs dynamic resource partition enforced by convention No-op — separation clarifies; invariant holds by domain (note paths must have .md/.org extension, can't collide with export/*)
3 Hickey _emanoteConfigLiveModelRef names the implementation, not the purpose Fixed — folded into Lowy F1 (73409d8)
4 Lowy Raw IORef leaks into EmanoteConfig as an implementation detail Fixed (73409d8)
5 Lowy tapModel will need fanout in phase 4 Deferred to phase 4 (docstring added in 73409d8)
6 Lowy withModel 503 is a startup-race artifact, not a real error Deferred to phase 4 (docstring added in 7ba1a29)
7 Lowy IORef → TVar blast radius No-op — contingent on #4; now local to two call sites after fix

Hickey rationale

The only real complecting find was F1: the instructions string hand-rolled the per-note URI prefix as a fourth literal of a fact already captured by noteUriPrefix. Introducing noteUriTemplate (now the single source of truth for the RFC 6570 template, used by both instructions and noteTemplate) resolves it.

F2 (merging staticResources + noteResources) was declined because the separation clarifies intent rather than interleaving concerns — one is a constant, the other is a projection of the model. The disjointness invariant is domain-enforced (note paths require .md/.org extensions), not a silent fragility.

F3 converged with Lowy F1 and was fixed there.

Lowy rationale

F1 (_emanoteConfigLiveModelRef) was the load-bearing fix. Exposing a raw IORef (Maybe Model) in EmanoteConfig conflated what we need (subscribe to model updates) with how we store it (a ref). Replaced with _emanoteConfigOnModelUpdate :: Maybe (Model -> IO ()) — a subscription hook. Emanote.run now owns the IORef privately and injects a one-line writer as the observer; phase 4's fanout refactor becomes local to tapModel and Emanote.run, with no ripple into the config record.

F2 and F3 are both deferred to phase 4 with explicit doc-comments naming the expected refactor. tapModel's comment calls out the fanout transition explicitly so phase 4 implementers don't need to re-derive it; withModel's comment documents that the 503 path exists only because race_ gives no ordering guarantee between Warp's bind and Ema's first snapshot. Phase 4 should remove that path by deferring MCP.run until the first model is published.

F4 becomes a non-concern once F1 is fixed: the IORef → TVar migration is now a 3-line change inside Emanote.run and tapModel, with no impact on EmanoteConfig.

@srid
Copy link
Copy Markdown
Owner Author

srid commented Apr 23, 2026

/do results

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-mcp skill. The skill saved the MCP-protocol rediscovery, but the main research spend was on Emanote's own layers — EmanoteConfig, Dynamic, export module surfaces. A companion emanote-architecture skill 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-mcp skill is pre-loaded and include "tools live under the same IORef (Maybe Model) as resources" in the invocation — the MCP tool surface is shaped like emanote/src/Emanote/MCP.hs:readResource dispatch, just with different inputs.

Workflow completed at 2026-04-23.

srid added 2 commits April 23, 2026 17:07
'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.
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.
srid added a commit to srid/ema that referenced this pull request Apr 24, 2026
**`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
```
Comment thread emanote/src/Emanote.hs Outdated
Comment thread emanote/CHANGELOG.md Outdated
srid added 5 commits April 24, 2026 14:45
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.
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