Skip to content

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

Merged
srid merged 30 commits into
masterfrom
feat/mcp-server-phase2
May 25, 2026
Merged

MCP server, phase 2: notebook-backed resources#649
srid merged 30 commits into
masterfrom
feat/mcp-server-phase2

Conversation

@srid

@srid srid commented Apr 23, 2026

Copy link
Copy Markdown
Owner

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, reuses renderJSONExport) — also the discovery surface for note paths
  • emanote://note/{path} — per-note reads via the URI template advertised through resources/templates/list

resources/list returns 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 CLI emanote export --format=content still produces that artifact for human/script use. Full discussion in the changelog and docs/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-search resources/list (Claude Code, opencode) won't surface individual notes there, so users reference notes by asking the model instead. Phase 3 will add a find_notes tool 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 Dynamic is push-only — there's no pull-side API. The branch resolves this with a single call into Ema.Dynamic.currentValue, which returns both a non-blocking snapshot reader and a re-wrapped Dynamic that still pushes downstream:

rawDyn <- siteInput @SiteRoute (Ema.CLI.action emaCli) cfg
case CLI.runMcpPort runCmd of
  Nothing  -> Ema.runSiteWithInput @SiteRoute emaCfg rawDyn >>= postRun cfg
  Just p   -> do
    (readEma, wrapped) <- currentValue rawDyn
    let readLiveModel = unModelEma <$> readEma
    race_ (MCP.run p verbose readLiveModel)
          (Ema.runSiteWithInput @SiteRoute emaCfg wrapped >>= postRun cfg)

The IO Model reader is passed directly as a function argument to MCP.run :: Int -> Bool -> IO Model -> IO (). There is no shared IORef, no new EmanoteConfig field, and no startup race: currentValue blocks on the Dynamic'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:

Module Responsibility
Emanote.MCP.Uri URI schema (emanote://…) — the breaking-change surface
Emanote.MCP.Catalog Notebook → NotebookResource / ResourceBody projection, MIME
Emanote.MCP.Handlers MCP wire-type adapters; URI dispatch; resource templates by kind
Emanote.MCP.Server Warp setup; server identity, capabilities; instructions
Emanote.MCP.Types Package-level MCPHandlerState / MCPHandlerUser instances

The server-side instructions string is generated by iterating Catalog.staticResources and Handlers.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 ResourceTemplate is 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 from kindMime :: ResourceKind -> Text — one source of truth, no per-record duplication.

Per-route algorithmic complexity

With N = note count and R = total resolved relations:

MCP method Cost in notebook size
initialize O(1)
resources/list O(1)
resources/templates/list O(1)
resources/read emanote://export/metadata O(N + R)
resources/read emanote://note/{path} O(log N + |note|)

Reads are uncached; phase 4's subscriptions replace polling with push for clients that opt in.

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.

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 the currentValue-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 with currentValue'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 curl recipe. A dedicated test-suite for the MCP transport should land alongside phase 3 (tools), where it gives more leverage.

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

srid commented Apr 23, 2026

Copy link
Copy Markdown
Owner Author

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

srid commented Apr 23, 2026

Copy link
Copy Markdown
Owner Author

/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 6 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.
# Conflicts:
#	emanote/CHANGELOG.md
#	flake.lock
@dpwiz

dpwiz commented May 23, 2026

Copy link
Copy Markdown

Any ETA on this landing?

I run emanote on this thingy: Markdown 422 8288 0 17978 (cloc)
And my agent would like some help 😅

srid and others added 5 commits May 25, 2026 10:04
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.
@srid

srid commented May 25, 2026

Copy link
Copy Markdown
Owner Author

@dpwiz Yea, I'll try to get this merged. Note that emanote://export/content is probably unfit for larger notebooks, but this is just phase 2. In phase 3, I'll add more granular MCP routes. If you have any feedback, let me know.

srid added 8 commits May 25, 2026 12:42
…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.
@srid

srid commented May 25, 2026

Copy link
Copy Markdown
Owner Author

Hickey/Lowy Analysis (polish pass)

Second structural review, run cold against the post-refactor branch (git diff origin/master...HEAD). Distinct from the first ledger above — that one covered the original _emanoteConfigOnModelUpdate architecture; this one covers the currentValue architecture plus the resource catalog as it stands.

# Lens Finding Disposition
1 Hickey MIME duplicated across resourceMime and resourceBodyMime Fixed (b7580ac)
2 Hickey MCPHandlerState / MCPHandlerUser orphans in Handlers.hs Fixed (beefc27)
3 Hickey Temporal coupling of readLiveModel to race_ arm undocumented at callsite Fixed (f331ec3)
4 Hickey ToolsCapability advertised with zero tools Fixed (4dac29a)
5 Lowy Catalog carries MCP presentation fields No-op (rationale below)
6 Lowy ResourceKind lives in Catalog but is a wire discriminator No-op (rationale below)
7 Lowy Resource templates hardcoded in Handlers, independent of catalog enumeration Fixed (e06f552)
8 Lowy URI parse failure, lookup miss, and IO failure all return identical 404 Fixed (e4e4d18)
9 Lowy Server.instructions duplicates Catalog descriptions Fixed (f3a0199)

Hickey rationale

Four findings landed, all narrow. #1resourceMime and resourceBodyMime were the same fact in two records, held consistent only by matching string literals. Replaced with kindMime :: ResourceKind -> Text and dropped both fields. #2 — the type-family instances were package-level policy living inside a handler module; moved to a dedicated Emanote.MCP.Types so a future second transport can import them without depending on handlers. #3 — a one-line doc-comment mirroring the contract already documented in Server.hs. #4tools = Just {} plus zero tool handlers is a false advertisement; flipped to Nothing until phase 3 lands.

Lowy rationale

Three findings landed, two declined.

#7 added an exhaustiveness gate over ResourceKind constructors — templateFor :: ResourceKind -> Maybe ResourceTemplate is the explicit yes/no for "does this kind get a URI template?". A new constructor now triggers a GHC warning instead of a silent omission. #8 distinguished three real failure modes that had collapsed into a single Nothing → 404: 400 for unrecognized URIs, 404 for catalog misses, with an Either CatalogError ResourceBody return shape ready for a 500-class IO error if one ever surfaces. #9 made Server.instructions iterate Catalog.staticResources and Handlers.templateFor — descriptions now live in one place and the LLM-facing instructions update automatically when a kind is added.

#5 (strip MCP presentation fields from NotebookResource) was declined: resourceName, resourceTitle, resourceDescription are general resource-catalog metadata (any REST/GraphQL surface would carry them too), not MCP-shaped. Catalog's "MCP-independent" docstring claim stands. The MIME piece of this finding was the one real duplication and got fixed by Hickey #1.

#6 (move ResourceKind from Catalog to Uri) was declined: the kind constructors (MetadataJson, ContentMarkdown, Note FilePath) are notebook vocabulary — what kinds of things the notebook exposes — not URI vocabulary. The fact that the wire URIs mirror them 1:1 is a translation property of Uri.hs, not evidence that the kinds belong there. A second transport with a different addressing scheme would still see the same three kinds.

Coda

Cross-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.

@srid

srid commented May 25, 2026

Copy link
Copy Markdown
Owner Author

/do --from polish results

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 6268bddd7ba1a295. 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 build once at the end of the batch (after all fixes apply cleanly) and then splitting into individual commits via git add -p would shave ~2 min, at the cost of any one fix that breaks the build going undetected until the batch finishes.
  • just fmt invoked the project's wrapped fourmolu (2-space) while /code-police's elegance pass invoked an unwrapped fourmolu (4-space), requiring a re-run of just fmt to settle. Worth either pinning /code-police to call just fmt instead of bare fourmolu, 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-smoke recipe would let future polish runs assert wire-level correctness without an ad-hoc script.

Workflow completed at 2026-05-25.

srid added 2 commits May 25, 2026 13:50
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.
@srid

srid commented May 25, 2026

Copy link
Copy Markdown
Owner Author

@dpwiz Yea, I'll try to get this merged. Note that emanote://export/content is probably unfit for larger notebooks, but this is just phase 2. In phase 3, I'll add more granular MCP routes. If you have any feedback, let me know.

Feedback can go under the master issue here: #645

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.
@srid

srid commented May 25, 2026

Copy link
Copy Markdown
Owner Author

claude-code mcp on ./docs

image

@srid
srid marked this pull request as ready for review May 25, 2026 20:22
@srid
srid merged commit 214480b into master May 25, 2026
6 checks passed
@srid
srid deleted the feat/mcp-server-phase2 branch May 25, 2026 20:22
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.

2 participants