Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
33bde3b
feat(mcp): expose notebook resources (phase 2)
srid Apr 23, 2026
6268bdd
refactor(hickey): derive MCP instructions text from URI constants
srid Apr 23, 2026
73409d8
refactor(lowy): replace MCP model ref with Model -> IO () observer
srid Apr 23, 2026
7ba1a29
refactor(lowy): document withModel 503 as startup-race artifact
srid Apr 23, 2026
6449a82
refactor(police): elegance — extract readNoteResource from readResource
srid Apr 23, 2026
de0bb86
chore: wire emanote MCP server into just run + apm.yml
srid Apr 23, 2026
7a6d6ca
chore: register emanote MCP server in .mcp.json
srid Apr 23, 2026
ff3b640
refactor(mcp): use Ema.runSiteWithInput; drop LiveModel handle
srid Apr 24, 2026
56007be
chore(flake): bump ema input to master
srid Apr 24, 2026
e442737
refactor(emanote): unify Run branches on runSiteWithInput
srid Apr 24, 2026
d7470d7
refactor(mcp): extract notebook resource catalog
srid Apr 24, 2026
7ab0367
refactor(mcp): colocate Catalog under Emanote.MCP
srid Apr 24, 2026
8abdba3
refactor(mcp): split MCP into Uri/Handlers/Server submodules
srid Apr 24, 2026
f6b0f9f
Merge remote-tracking branch 'origin/master' into feat/mcp-server-phase2
srid May 9, 2026
c7ab832
Merge branch 'master' into feat/mcp-server-phase2
srid May 25, 2026
17ef8c8
Merge branch 'master' into feat/mcp-server-phase2
srid May 25, 2026
6698c6e
Merge branch 'master' into feat/mcp-server-phase2
srid May 25, 2026
4dac29a
refactor(hickey): drop ToolsCapability advertisement until tools land
srid May 25, 2026
f331ec3
refactor(hickey): document race_ ordering at MCP startup callsite
srid May 25, 2026
beefc27
refactor(hickey): extract MCP type-family instances into Emanote.MCP.…
srid May 25, 2026
b7580ac
refactor(hickey): derive MCP resource MIME from ResourceKind
srid May 25, 2026
e06f552
refactor(lowy): derive note ResourceTemplate from ResourceKind
srid May 25, 2026
e4e4d18
refactor(lowy): distinguish unknown URI from missing resource
srid May 25, 2026
f3a0199
refactor(lowy): generate MCP server instructions from catalog descrip…
srid May 25, 2026
1d0e6e6
refactor(police): elegance — use record wildcard in kindMime Note arm
srid May 25, 2026
2f50b1a
refactor(police): elegance — point-free readResource ContentMarkdown arm
srid May 25, 2026
46cdb2c
refactor(police): elegance — uniform list construction in instructions
srid May 25, 2026
891a352
feat(mcp): drop per-note enumeration from resources/list
srid May 25, 2026
221669a
docs(mcp): annotate per-route algorithmic complexity + restructure
srid May 25, 2026
8c67792
feat(mcp)!: drop emanote://export/content resource
srid May 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
"chrome-devtools": {
"command": "just",
"args": ["mcp-chrome-devtools"]
},
"emanote": {
"type": "http",
"url": "http://localhost:8079/mcp"
}
}
}
6 changes: 5 additions & 1 deletion apm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,9 @@ dependencies:
- juspay/skills/skills/nix-playwright
- juspay/nix-chrome-devtools-mcp
- anthropics/skills/skills/frontend-design
mcp: []
mcp:
- name: emanote
registry: false
transport: http
url: http://localhost:8079/mcp
scripts: {}
38 changes: 36 additions & 2 deletions docs/guide/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ slug: mcp
# MCP server

> [!warning] Work in progress
> MCP support is rolling out in phases ([#645](https://github.com/srid/emanote/issues/645)). The current release ships only the HTTP transport and the lifecycle handshakeresources, tools, and subscriptions arrive in later PRs. Expect the surface to grow and the wire details to shift until this notice is removed.
> MCP support is rolling out in phases ([#645](https://github.com/srid/emanote/issues/645)). **Read-only resources** are live as of this releasequery tools and subscriptions arrive in later PRs. Expect the tool/prompt surface to grow until this notice is removed.

Emanote can expose an [MCP (Model Context Protocol)](https://modelcontextprotocol.io) endpoint beside its [live server](https://ema.srid.ca/topics/live-server), so that [Claude Code](https://claude.com/claude-code), [Codex](https://github.com/openai/codex), or any other MCP-aware client can query your notebook directly from the same process that renders it.

Expand Down Expand Up @@ -37,7 +37,7 @@ Claude Code reads MCP server configuration from `.mcp.json` in your project root
}
```

Start Emanote in one terminal (`emanote run --mcp-port 8079`), launch Claude Code in the same directory, and it will connect on startup. Use `/mcp` inside Claude Code to verify the server appears and list its tools/resources.
Start Emanote in one terminal (`emanote run --mcp-port 8079`), launch Claude Code in the same directory, and it will connect on startup. Use `/mcp` inside Claude Code to verify the server appears and list its resources.

### Codex

Expand All @@ -63,6 +63,40 @@ curl -sS -N -X POST http://localhost:8079/mcp \

You should see an SSE `event: message` frame carrying the server's implementation metadata and advertised capabilities.

## Resources

Emanote advertises the notebook under the `emanote://` scheme as one static export plus a per-note URI template:

| URI | MIME | What it returns |
|---|---|---|
| `emanote://export/metadata` | `application/json` | Metadata for every note — titles, source paths, parent routes, resolved links. Same shape as [`emanote export --format=metadata`](export.md). Use this to discover paths. |
| `emanote://note/{path}` | `text/markdown` | One note, by its source path (e.g. `emanote://note/guide/mcp.md`). Prefixed with a header block (`<!-- Source … -->`, `<!-- URL … -->`, `<!-- Title … -->`, `<!-- Wikilinks … -->`). |

`resources/list` returns only the metadata export. Emanote intentionally does **not** enumerate one entry per note: that scales linearly with notebook size and inflates context on every poll. `resources/templates/list` advertises the `emanote://note/{path}` template for clients that support [RFC 6570 URI templates](https://datatracker.ietf.org/doc/html/rfc6570); to address a specific note, construct a URI from the template and call `resources/read` directly. Discover the set of valid paths from `emanote://export/metadata` (every note's `srcPath`).

> [!note] No bundled-content export
> Earlier drafts of phase 2 exposed `emanote://export/content` (every note concatenated into a single Markdown blob). It was removed before merge: the same information is available via metadata + per-note reads, the blob blows context budgets on any non-trivial notebook (a 422-note notebook is well past any reasonable LLM window), and an MCP client that polls it re-reads the whole disk every time. The `emanote export --format=content` CLI still produces this artifact for human/script use; MCP is the wrong transport for batch export.

### Algorithmic complexity

Per-request cost, where _N_ = number of notes in the model and _R_ = total resolved relations (wikilinks + transclusions) across all notes:

| MCP method | Cost in notebook size |
|---|---|
| `initialize` | **O(1)** |
| `resources/list` | **O(1)** — one fixed static entry, independent of _N_ |
| `resources/templates/list` | **O(1)** — currently one template (per-note); grows with templated kinds, not with notebook size |
| `resources/read emanote://export/metadata` | **O(N + R)** — iterates every note and every relation; JSON-encodes the result |
| `resources/read emanote://note/{path}` | **O(log N + \|note\|)** — ixset lookup plus one file read |

Reads are uncached: every `resources/read` re-runs against the live model. There is no per-client throttling or coalescing — a client that loops over per-note reads will re-traverse the disk each time. Phase 4 (subscriptions) replaces polling with push notifications and removes the per-poll cost for clients that opt in.

### Per-client behaviour

- **Codex** sees the template in the model-side `list_mcp_resource_templates` tool and can call `read_mcp_resource` against any path. Works out of the box.
- **Claude Code**'s model-side read tool ([docs](https://code.claude.com/docs/en/mcp.md#use-mcp-resources)) reads any URI the model constructs, including ones derived from the template. The `@`-mention picker, however, fuzzy-searches only the enumerated `resources/list` entries — so users won't see individual notes there and must reference them by asking the model (e.g. "read `guide/mcp.md` from the notebook") instead of `@`-mentioning them. Phase 3 will add a `find_notes` tool to make this lookup explicit.
- **opencode** populates its attach picker from `resources/list` only; per-note attachment via UI is unavailable without an enumeration. Same model-driven workaround as Claude Code applies when the model itself drives reads.

## Debugging

- Pass `-v` / `--verbose` to Emanote and the underlying `mcp` library will print one `[request]` / `[response]` line per JSON-RPC call to stdout. Useful when a client is misbehaving or you want to see exactly what a tool call looks like.
Expand Down
2 changes: 1 addition & 1 deletion emanote/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
- **Inline-SVG diagrams (`d2`, `cetz`) + Lua-filter error protocol** ([#625](https://github.com/srid/emanote/issues/625)) — bundled `pandoc-ext/diagram` opt-in, offline `@preview/cetz` cache, injected `emanote.error_block` helper, `--allow-broken-lua-filters` flag. See [[diagrams]] and [[writing-filters]].
- **Lua filter hot-reload, bundled filters, and render-time filters** (closes the MVP→final half of [#263](https://github.com/srid/emanote/issues/263), tracked in [#721](https://github.com/srid/emanote/issues/721)): editing a `.lua` file referenced from any note's Markdown `pandoc.filters.parse` / `pandoc.filters.render.html` frontmatter or Org `#+PANDOC_FILTERS_PARSE` / `#+PANDOC_FILTERS_RENDER_HTML` keywords now refreshes every dependent note in place — no `touch foo.md` workaround required. Parse-time filters run with `FORMAT == "markdown"` and with IO-capable Lua/Pandoc APIs disabled; HTML render-time filters run with `FORMAT == "html"` and receive the note's effective metadata in `doc.meta`. The reverse-dependency index keys edges by the filter path *as written* (typically layer-relative), so a filter referenced before it exists on disk also gets an edge: creating the file later triggers re-parse. `.lua` is also now claimed as its own file type so edits route through the hot-reload path while filter sources remain wikilinkable as source files. Emanote now bundles the maintained [`pandoc-ext/list-table`](https://github.com/pandoc-ext/list-table) filter plus an Emanote-specific `wordcount.lua` demo filter, so notes can declare `pandoc.filters.parse: [lua-filters/list-table.lua]` without copying the filter into the notebook. The docs include a render-time custom `slides.lua` powering [`/slides`](https://emanote.srid.ca/slides), itself a Markdown deck *about* Lua filters. Built on `unionmount`'s new `unionMountStreaming` so the patch handler reads the running model directly without maintaining a parallel mirror.
- **`.emanoteignore`** (closes [#228](https://github.com/srid/emanote/issues/228)): each notebook layer may now ship a top-level `.emanoteignore` listing `FilePattern` entries (one per line; blanks and `#`-comment lines skipped) to exclude files from the model. Patterns are scoped to the layer they live in — a pattern in layer A's file does not affect layer B — and are merged with Emanote's universal ignores (`**/.*/**`, `**/*~`, `-/**`, and `**/.emanoteignore` itself). Built on per-source ignore support added to `unionmount`. **Behavior change:** `flake.nix` and `flake.lock` are no longer ignored by default — users who run Emanote from inside a Nix flake notebook should add those entries to their own `.emanoteignore`.
- **MCP server** scaffolding: new `emanote run --mcp-port PORT` flag runs an in-process Model Context Protocol HTTP endpoint beside the live server. Phase 1 ships only the lifecycle handshake and empty resource/tool inventories; richer surfaces follow in later phases ([#645](https://github.com/srid/emanote/issues/645))
- **MCP server**: new `emanote run --mcp-port PORT` flag runs an in-process Model Context Protocol HTTP endpoint beside the live server. Notebook data is exposed as read-only resources — `emanote://export/metadata` (JSON, also the discovery surface for note paths) and per-note reads via the `emanote://note/{path}` URI template. `resources/list` returns only the metadata export; clients address individual notes through the template, keeping the response size independent of notebook size. Query tools and subscriptions follow in later phases ([#645](https://github.com/srid/emanote/issues/645), [#649](https://github.com/srid/emanote/pull/649))
- Default template chrome can now be localized through `page.lang` and `template.i18n`. English remains the fallback language, and French and Chinese strings are included for the built-in navigation, search, copy buttons, labels, and error chrome (closes [#486](https://github.com/srid/emanote/issues/486), [#722](https://github.com/srid/emanote/pull/722)).
- Callouts: support **nested** and **foldable** Obsidian-style callouts (`> [!type]+` / `[!type]-`), rendering as `<details>`/`<summary>` ([#465](https://github.com/srid/emanote/issues/465), [#652](https://github.com/srid/emanote/pull/652))
- **Tailwind v3 → v4 migration** with CSS-variable design tokens ([#633](https://github.com/srid/emanote/pull/633))
Expand Down
5 changes: 5 additions & 0 deletions emanote/emanote.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,11 @@ library
Emanote
Emanote.CLI
Emanote.MCP
Emanote.MCP.Catalog
Emanote.MCP.Handlers
Emanote.MCP.Server
Emanote.MCP.Types
Emanote.MCP.Uri
Emanote.Model
Emanote.Model.Calendar
Emanote.Model.Calendar.Parser
Expand Down
33 changes: 26 additions & 7 deletions emanote/src/Emanote.hs
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ import Ema (
SiteConfig (SiteConfig),
fromPrism_,
runSiteWith,
runSiteWithInput,
toPrism_,
)
import Ema.CLI qualified
import Ema.Dynamic (Dynamic (Dynamic))
import Ema.Dynamic (Dynamic (Dynamic), currentValue)
import Emanote.CLI qualified as CLI
import Emanote.MCP qualified as MCP
import Emanote.Model.Graph qualified as G
Expand Down Expand Up @@ -67,17 +68,35 @@ modelUpdateCachedFields model =

defaultEmanoteConfig :: CLI.Cli -> EmanoteConfig
defaultEmanoteConfig cli =
EmanoteConfig cli id defaultEmanotePandocRenderers False
EmanoteConfig
{ _emanoteConfigCli = cli
, _emanoteConfigNoteFn = id
, _emanoteConfigPandocRenderers = defaultEmanotePandocRenderers
, _emanoteCompileTailwind = False
}

run :: EmanoteConfig -> IO ()
run cfg@EmanoteConfig {..} = do
case CLI.cmd _emanoteConfigCli of
CLI.Cmd_Run runCmd -> do
let emaCfg = SiteConfig (toEmaCli (CLI.Cmd_Run runCmd)) def
ema = Ema.runSiteWith @SiteRoute emaCfg cfg >>= postRun cfg
case CLI.runMcpPort runCmd of
Nothing -> ema
Just port -> race_ (MCP.run port (CLI.verbose _emanoteConfigCli)) ema
let emaCli = toEmaCli (CLI.Cmd_Run runCmd)
emaCfg = SiteConfig emaCli def
flip runLoggerLoggingT (Ema.CLI.getLogger emaCli) $ do
rawDyn <- siteInput @SiteRoute (Ema.CLI.action emaCli) cfg
case CLI.runMcpPort runCmd of
Nothing ->
Ema.runSiteWithInput @SiteRoute emaCfg rawDyn >>= liftIO . postRun cfg
Just port -> do
-- `currentValue` seeds an IORef with the Dynamic's initial value
-- before returning, so `readLiveModel` is non-blocking from the
-- first call. `wrapped` must be consumed on the other arm of
-- `race_` to keep `readLiveModel` advancing past that initial
-- snapshot.
(readEma, wrapped) <- currentValue rawDyn
let readLiveModel = unModelEma <$> readEma
race_
(liftIO $ MCP.run port (CLI.verbose _emanoteConfigCli) readLiveModel)
(Ema.runSiteWithInput @SiteRoute emaCfg wrapped >>= liftIO . postRun cfg)
CLI.Cmd_Gen dest -> do
let emaCfg = SiteConfig (toEmaCli (CLI.Cmd_Gen dest)) def
Ema.runSiteWith @SiteRoute emaCfg cfg >>= postRun cfg
Expand Down
113 changes: 14 additions & 99 deletions emanote/src/Emanote/MCP.hs
Original file line number Diff line number Diff line change
@@ -1,106 +1,21 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# OPTIONS_GHC -Wno-orphans #-}
{- | MCP (Model Context Protocol) server for Emanote.

{- | MCP (Model Context Protocol) server.
Exposes the notebook model as read-only MCP resources:

Runs alongside the Emanote live server in the same process, exposing a
read-only surface over HTTP. Advertises the @resources@ and @tools@
capabilities; current handlers return empty inventories and a
not-found reply for unknown URIs.
* @emanote:\/\/export\/metadata@ — JSON metadata for every note
* @emanote:\/\/export\/content@ — all notes concatenated as a single Markdown document
* @emanote:\/\/note\/{path}@ — an individual note by its source path

Umbrella module. Implementation lives in:

* "Emanote.MCP.Types" — package-level type-family instances
* "Emanote.MCP.Catalog" — notebook resource catalog (what's available, how to read it)
* "Emanote.MCP.Uri" — URI wire schema and 'ResourceKind' \<-\> URI translation
* "Emanote.MCP.Handlers" — request handlers adapting the catalog to MCP wire types
* "Emanote.MCP.Server" — Warp setup, server identity, capabilities, instructions
-}
module Emanote.MCP (
run,
) where

import Data.Version (showVersion)
import MCP.Server (
Implementation (..),
ListResourcesResult (..),
LoggingLevel (..),
MCPHandlerState,
MCPHandlerUser,
MCPServerState (..),
ProcessResult (..),
ReadResourceParams (..),
ResourcesCapability (..),
ServerCapabilities (..),
ToolsCapability (..),
defaultProcessHandlers,
initMCPServerState,
listResourcesHandler,
readResourceHandler,
simpleHttpApp,
withToolHandlers,
)
import MCP.Server qualified as MCP
import Network.Wai.Handler.Warp qualified as Warp
import Paths_emanote qualified
import Relude
import System.IO (hPutStrLn)

type instance MCPHandlerState = ()

-- | Unused: 'simpleHttpApp' bypasses the JWT pipeline that would consume this.
type instance MCPHandlerUser = ()

{- | Start the MCP HTTP server on the given port.

This blocks. Intended to be run concurrently with the Emanote live
server via 'UnliftIO.Async.race_'. Prints a single @listening@ line
to stderr once Warp has bound the socket. When @verbose@ is set, the
underlying @mcp@ library emits one line per request/response to
stdout.
-}
run :: Int -> Bool -> IO ()
run port verbose = do
stateVar <-
newMVar
(initMCPServerState () Nothing Nothing capabilities implementation instructions handlers)
{ mcp_log_level = Just (if verbose then Debug else Warning)
}
let settings =
Warp.defaultSettings
& Warp.setPort port
& Warp.setBeforeMainLoop
(hPutStrLn stderr $ "[mcp] listening on http://localhost:" <> show port <> "/mcp")
Warp.runSettings settings (simpleHttpApp stateVar)

implementation :: Implementation
implementation =
Implementation
{ MCP.name = "emanote"
, version = toText $ showVersion Paths_emanote.version
, title = Just "Emanote MCP Server"
}

instructions :: Maybe Text
instructions = Just "Emanote notebook exposed over MCP."

capabilities :: ServerCapabilities
capabilities =
ServerCapabilities
{ logging = Nothing
, prompts = Nothing
, resources = Just ResourcesCapability {listChanged = Nothing, subscribe = Nothing}
, tools = Just ToolsCapability {listChanged = Nothing}
, completions = Nothing
, experimental = Nothing
}

handlers :: MCP.ProcessHandlers
handlers =
withToolHandlers
[]
defaultProcessHandlers
{ listResourcesHandler = Just $ \_ ->
pure
$ ProcessSuccess
$ ListResourcesResult
{ resources = []
, nextCursor = Nothing
, MCP._meta = Nothing
}
, readResourceHandler = Just $ \ReadResourceParams {uri} ->
pure $ ProcessRPCError 404 $ "Resource not found: " <> uri
}
import Emanote.MCP.Server (run)
Loading
Loading