From 33bde3b0929e843cac027684318011616150daea Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Thu, 23 Apr 2026 16:38:41 -0400 Subject: [PATCH 01/26] feat(mcp): expose notebook resources (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/guide/mcp.md | 14 +- emanote/CHANGELOG.md | 2 +- emanote/src/Emanote.hs | 38 ++++- emanote/src/Emanote/MCP.hs | 195 +++++++++++++++++++++++--- emanote/src/Emanote/Source/Dynamic.hs | 4 + 5 files changed, 229 insertions(+), 24 deletions(-) diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 3d3f3a816..0e923b5b3 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -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 handshake — resources, 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 release — query 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|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. @@ -39,6 +39,18 @@ 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. +## Resources + +Emanote advertises the notebook as three URI schemes under the `emanote://` scheme: + +| 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). | +| `emanote://export/content` | `text/markdown` | All notes concatenated into a single Markdown document with delimiters and an LLM-oriented preamble. Same shape as [`emanote export --format=content`](export.md). | +| `emanote://note/{path}` | `text/markdown` | One note, by its source path (e.g. `emanote://note/guide/mcp.md`). Prefixed with a header block (``, ``, ``, ``). | + +`resources/list` returns the two static exports plus one entry per note; `resources/templates/list` advertises the `emanote://note/{path}` template for clients that support [RFC 6570 URI templates](https://datatracker.ietf.org/doc/html/rfc6570). + ### Codex Codex uses [`~/.codex/config.toml`](https://github.com/openai/codex#mcp-servers) for MCP servers. HTTP transport wiring looks like: diff --git a/emanote/CHANGELOG.md b/emanote/CHANGELOG.md index ac5362031..cf14275dc 100644 --- a/emanote/CHANGELOG.md +++ b/emanote/CHANGELOG.md @@ -4,7 +4,7 @@ **Notable features** -- **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), `emanote://export/content` (single-file Markdown dump), and `emanote://note/{path}` for individual notes. Query tools and subscriptions follow in later phases ([#645](https://github.com/srid/emanote/issues/645)) - **Tailwind v3 → v4 migration** with CSS-variable design tokens ([#633](https://github.com/srid/emanote/pull/633)) - Built-in static syntax highlighting using skylighting, replacing client-side JS highlighters ([#624](https://github.com/srid/emanote/pull/624)) - Built-in static math rendering (LaTeX → MathML at build time via `texmath`) ([#639](https://github.com/srid/emanote/pull/639)) diff --git a/emanote/src/Emanote.hs b/emanote/src/Emanote.hs index ae0d16d78..fb4025a01 100644 --- a/emanote/src/Emanote.hs +++ b/emanote/src/Emanote.hs @@ -55,9 +55,30 @@ instance EmaSite SiteRoute where type SiteArg SiteRoute = EmanoteConfig siteInput cliAct cfg = do model <- emanoteSiteInput cliAct cfg - pure $ model <&> modelUpdateCachedFields + let tapped = model <&> modelUpdateCachedFields + case _emanoteConfigLiveModelRef cfg of + Nothing -> pure tapped + Just ref -> tapModelRef ref tapped siteOutput = View.emanoteSiteOutput +{- | Mirror every Dynamic value (initial + updates) into the given ref so +out-of-band readers (the MCP server) can snapshot the live model. +-} +tapModelRef :: + (MonadIO m) => + IORef (Maybe Model.Model) -> + Dynamic m Model.ModelEma -> + m (Dynamic m Model.ModelEma) +tapModelRef ref (Dynamic (x0, updater)) = do + liftIO $ writeIORef ref $ Just (unModelEma x0) + pure + $ Dynamic + ( x0 + , \send -> updater $ \x -> do + liftIO $ writeIORef ref $ Just (unModelEma x) + send x + ) + -- | Populate model fields that needs to be computed once per update. modelUpdateCachedFields :: Model.ModelEma -> Model.ModelEma modelUpdateCachedFields model = @@ -67,17 +88,22 @@ modelUpdateCachedFields model = defaultEmanoteConfig :: CLI.Cli -> EmanoteConfig defaultEmanoteConfig cli = - EmanoteConfig cli id defaultEmanotePandocRenderers False + EmanoteConfig cli id defaultEmanotePandocRenderers False Nothing 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 + Nothing -> + let emaCfg = SiteConfig (toEmaCli (CLI.Cmd_Run runCmd)) def + in Ema.runSiteWith @SiteRoute emaCfg cfg >>= postRun cfg + Just port -> do + modelRef <- newIORef Nothing + let cfg' = cfg {_emanoteConfigLiveModelRef = Just modelRef} + emaCfg = SiteConfig (toEmaCli (CLI.Cmd_Run runCmd)) def + ema = Ema.runSiteWith @SiteRoute emaCfg cfg' >>= postRun cfg' + race_ (MCP.run port (CLI.verbose _emanoteConfigCli) modelRef) ema CLI.Cmd_Gen dest -> do let emaCfg = SiteConfig (toEmaCli (CLI.Cmd_Gen dest)) def Ema.runSiteWith @SiteRoute emaCfg cfg >>= postRun cfg diff --git a/emanote/src/Emanote/MCP.hs b/emanote/src/Emanote/MCP.hs index b3387936d..089f96348 100644 --- a/emanote/src/Emanote/MCP.hs +++ b/emanote/src/Emanote/MCP.hs @@ -4,30 +4,53 @@ {- | MCP (Model Context Protocol) server. -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. +Runs alongside the Emanote live server in the same process, exposing the +notebook model as read-only MCP resources: + +* @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 + +The live model is shared with 'Emanote.run' via an 'IORef' populated by +'Emanote.tapModelRef' on every Ema update. -} module Emanote.MCP ( run, ) where +import Data.Text qualified as T import Data.Version (showVersion) +import Emanote.Model (Model) +import Emanote.Model qualified as M +import Emanote.Model.Note qualified as Note +import Emanote.Model.Title qualified as Tit +import Emanote.Route qualified as R +import Emanote.Route.Ext (LML (Md, Org)) +import Emanote.Route.ModelRoute (mkLMLRouteFromKnownFilePath) +import Emanote.View.Export.Content qualified as ExportContent +import Emanote.View.Export.JSON qualified as ExportJSON import MCP.Server ( Implementation (..), + ListResourceTemplatesResult (..), ListResourcesResult (..), LoggingLevel (..), MCPHandlerState, MCPHandlerUser, MCPServerState (..), + MCPServerT, ProcessResult (..), ReadResourceParams (..), + ReadResourceResult (..), + Resource (..), + ResourceContents (..), + ResourceTemplate (..), ResourcesCapability (..), ServerCapabilities (..), + TextResourceContents (..), ToolsCapability (..), defaultProcessHandlers, initMCPServerState, + listResourceTemplatesHandler, listResourcesHandler, readResourceHandler, simpleHttpApp, @@ -35,6 +58,7 @@ import MCP.Server ( ) import MCP.Server qualified as MCP import Network.Wai.Handler.Warp qualified as Warp +import Optics.Operators ((^.)) import Paths_emanote qualified import Relude import System.IO (hPutStrLn) @@ -51,12 +75,17 @@ 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. + +Reads the current model from the supplied ref, which +'Emanote.tapModelRef' populates on every Ema update. When the ref is +still 'Nothing' (a client arriving before Ema has produced its first +model), handlers reply with JSON-RPC 503 so clients can retry. -} -run :: Int -> Bool -> IO () -run port verbose = do +run :: Int -> Bool -> IORef (Maybe Model) -> IO () +run port verbose modelRef = do stateVar <- newMVar - (initMCPServerState () Nothing Nothing capabilities implementation instructions handlers) + (initMCPServerState () Nothing Nothing capabilities implementation instructions (handlers modelRef)) { mcp_log_level = Just (if verbose then Debug else Warning) } let settings = @@ -75,7 +104,15 @@ implementation = } instructions :: Maybe Text -instructions = Just "Emanote notebook exposed over MCP." +instructions = + Just + $ unlines + [ "Emanote notebook exposed over MCP." + , "Resources:" + , "- " <> metadataUri <> " — JSON metadata for every note (titles, paths, parents, links)" + , "- " <> contentUri <> " — all notes concatenated as a single Markdown document" + , "- " <> noteUriPrefix <> "{path} — individual note by source path (e.g. " <> noteUriPrefix <> "guide/mcp.md)" + ] capabilities :: ServerCapabilities capabilities = @@ -88,19 +125,145 @@ capabilities = , experimental = Nothing } -handlers :: MCP.ProcessHandlers -handlers = - withToolHandlers - [] - defaultProcessHandlers +metadataUri :: Text +metadataUri = "emanote://export/metadata" + +contentUri :: Text +contentUri = "emanote://export/content" + +noteUriPrefix :: Text +noteUriPrefix = "emanote://note/" + +noteUri :: R.LMLRoute -> Text +noteUri route = noteUriPrefix <> toText (ExportJSON.lmlSourcePath route) + +handlers :: IORef (Maybe Model) -> MCP.ProcessHandlers +handlers modelRef = + withToolHandlers [] + $ defaultProcessHandlers { listResourcesHandler = Just $ \_ -> + withModel modelRef $ \model -> + pure + $ ProcessSuccess + $ ListResourcesResult + { resources = staticResources <> noteResources model + , nextCursor = Nothing + , MCP._meta = Nothing + } + , listResourceTemplatesHandler = Just $ \_ -> pure $ ProcessSuccess - $ ListResourcesResult - { resources = [] + $ ListResourceTemplatesResult + { resourceTemplates = [noteTemplate] , nextCursor = Nothing , MCP._meta = Nothing } , readResourceHandler = Just $ \ReadResourceParams {uri} -> - pure $ ProcessRPCError 404 $ "Resource not found: " <> uri + withModel modelRef $ \model -> readResource model uri + } + +{- | Run the given action against the current model, or reply 503 if the model +ref is still empty (client arrived before Ema produced its first snapshot). +-} +withModel :: + IORef (Maybe Model) -> + (Model -> MCPServerT (ProcessResult a)) -> + MCPServerT (ProcessResult a) +withModel ref k = do + mModel <- liftIO $ readIORef ref + case mModel of + Nothing -> pure $ ProcessRPCError 503 "Emanote model not yet loaded; please retry" + Just model -> k model + +readResource :: Model -> Text -> MCPServerT (ProcessResult ReadResourceResult) +readResource model uri + | uri == metadataUri = + pure + $ ProcessSuccess + $ textResult uri (Just "application/json") + $ decodeUtf8 (ExportJSON.renderJSONExport model) + | uri == contentUri = do + body <- liftIO $ ExportContent.renderContentExport model + pure $ ProcessSuccess $ textResult uri (Just "text/markdown") body + | Just path <- T.stripPrefix noteUriPrefix uri + , Just route <- parseNoteRoute (toString path) = + case Note.lookupNotesByRoute route (model ^. M.modelNotes) of + Nothing -> pure $ ProcessRPCError 404 $ "Note not found: " <> uri + Just note -> do + mContent <- liftIO $ ExportContent.readNoteContent note + case mContent of + Nothing -> pure $ ProcessRPCError 404 $ "Note has no source file: " <> uri + Just content -> + let header = ExportContent.generateNoteHeader model note + in pure $ ProcessSuccess $ textResult uri (Just "text/markdown") (header <> content) + | otherwise = pure $ ProcessRPCError 404 $ "Resource not found: " <> uri + +parseNoteRoute :: FilePath -> Maybe R.LMLRoute +parseNoteRoute fp = + mkLMLRouteFromKnownFilePath Md fp <|> mkLMLRouteFromKnownFilePath Org fp + +textResult :: Text -> Maybe Text -> Text -> ReadResourceResult +textResult uri mime body = + ReadResourceResult + { contents = + [ TextResource + TextResourceContents + { MCP.uri = uri + , text = body + , mimeType = mime + , MCP._meta = Nothing + } + ] + , MCP._meta = Nothing + } + +staticResources :: [Resource] +staticResources = + [ Resource + { MCP.uri = metadataUri + , MCP.name = "Notebook metadata" + , MCP.title = Just "Notebook metadata (JSON)" + , MCP.description = Just "Notebook metadata as JSON: per-note titles, source paths, parent routes, and resolved links." + , MCP.mimeType = Just "application/json" + , size = Nothing + , annotations = Nothing + , MCP._meta = Nothing } + , Resource + { MCP.uri = contentUri + , MCP.name = "Notebook content (single-file)" + , MCP.title = Just "Notebook content (single-file Markdown)" + , MCP.description = Just "All notes concatenated into a single Markdown document, separated by '===' delimiters." + , MCP.mimeType = Just "text/markdown" + , size = Nothing + , annotations = Nothing + , MCP._meta = Nothing + } + ] + +noteResources :: Model -> [Resource] +noteResources model = + [ Resource + { MCP.uri = noteUri (Note._noteRoute note) + , MCP.name = toText (ExportJSON.lmlSourcePath (Note._noteRoute note)) + , MCP.title = Just $ Tit.toPlain (Note._noteTitle note) + , MCP.description = Nothing + , MCP.mimeType = Just "text/markdown" + , size = Nothing + , annotations = Nothing + , MCP._meta = Nothing + } + | note <- toList (model ^. M.modelNotes) + ] + +noteTemplate :: ResourceTemplate +noteTemplate = + ResourceTemplate + { MCP.name = "Notebook note" + , MCP.title = Just "Notebook note" + , uriTemplate = noteUriPrefix <> "{path}" + , MCP.description = Just "Individual note by source path, e.g. emanote://note/guide/mcp.md" + , MCP.mimeType = Just "text/markdown" + , annotations = Nothing + , MCP._meta = Nothing + } diff --git a/emanote/src/Emanote/Source/Dynamic.hs b/emanote/src/Emanote/Source/Dynamic.hs index 53c2ffac4..624b91202 100644 --- a/emanote/src/Emanote/Source/Dynamic.hs +++ b/emanote/src/Emanote/Source/Dynamic.hs @@ -6,6 +6,7 @@ module Emanote.Source.Dynamic ( EmanoteConfig (..), emanoteCompileTailwind, emanoteConfigCli, + emanoteConfigLiveModelRef, emanoteConfigNoteFn, emanoteConfigPandocRenderers, ) where @@ -44,6 +45,9 @@ data EmanoteConfig = EmanoteConfig -- ^ How to render Pandoc to Heist HTML. , _emanoteCompileTailwind :: Bool -- ^ Whether to replace Tailwind2 CDN with a minimized Tailwind3 CSS file. + , _emanoteConfigLiveModelRef :: Maybe (IORef (Maybe Model.Model)) + -- ^ When set, each model update is mirrored to this ref. Used by the MCP + -- server to read the live model snapshot without driving Ema's render loop. } {- | Make an Ema `Dynamic` for the Emanote model. From 6268bdddeb2f55f99ca282c7d069d4d82b9bf207 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Thu, 23 Apr 2026 16:43:17 -0400 Subject: [PATCH 02/26] refactor(hickey): derive MCP instructions text from URI constants 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. --- emanote/src/Emanote/MCP.hs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/emanote/src/Emanote/MCP.hs b/emanote/src/Emanote/MCP.hs index 089f96348..b2acedeba 100644 --- a/emanote/src/Emanote/MCP.hs +++ b/emanote/src/Emanote/MCP.hs @@ -111,7 +111,7 @@ instructions = , "Resources:" , "- " <> metadataUri <> " — JSON metadata for every note (titles, paths, parents, links)" , "- " <> contentUri <> " — all notes concatenated as a single Markdown document" - , "- " <> noteUriPrefix <> "{path} — individual note by source path (e.g. " <> noteUriPrefix <> "guide/mcp.md)" + , "- " <> noteUriTemplate <> " — individual note by source path (e.g. " <> noteUriPrefix <> "guide/mcp.md)" ] capabilities :: ServerCapabilities @@ -134,6 +134,12 @@ contentUri = "emanote://export/content" noteUriPrefix :: Text noteUriPrefix = "emanote://note/" +{- | RFC 6570 template for the per-note URI; referenced both in instructions +and in 'noteTemplate'. +-} +noteUriTemplate :: Text +noteUriTemplate = noteUriPrefix <> "{path}" + noteUri :: R.LMLRoute -> Text noteUri route = noteUriPrefix <> toText (ExportJSON.lmlSourcePath route) @@ -261,8 +267,8 @@ noteTemplate = ResourceTemplate { MCP.name = "Notebook note" , MCP.title = Just "Notebook note" - , uriTemplate = noteUriPrefix <> "{path}" - , MCP.description = Just "Individual note by source path, e.g. emanote://note/guide/mcp.md" + , uriTemplate = noteUriTemplate + , MCP.description = Just $ "Individual note by source path, e.g. " <> noteUriPrefix <> "guide/mcp.md" , MCP.mimeType = Just "text/markdown" , annotations = Nothing , MCP._meta = Nothing From 73409d8dc4ab96b5ac8769442835af6d2134868f Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Thu, 23 Apr 2026 16:44:51 -0400 Subject: [PATCH 03/26] refactor(lowy): replace MCP model ref with Model -> IO () observer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- emanote/src/Emanote.hs | 26 ++++++++++++++++---------- emanote/src/Emanote/MCP.hs | 4 ++-- emanote/src/Emanote/Source/Dynamic.hs | 11 +++++++---- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/emanote/src/Emanote.hs b/emanote/src/Emanote.hs index fb4025a01..80ef96e7b 100644 --- a/emanote/src/Emanote.hs +++ b/emanote/src/Emanote.hs @@ -56,26 +56,32 @@ instance EmaSite SiteRoute where siteInput cliAct cfg = do model <- emanoteSiteInput cliAct cfg let tapped = model <&> modelUpdateCachedFields - case _emanoteConfigLiveModelRef cfg of + case _emanoteConfigOnModelUpdate cfg of Nothing -> pure tapped - Just ref -> tapModelRef ref tapped + Just onUpdate -> tapModel onUpdate tapped siteOutput = View.emanoteSiteOutput -{- | Mirror every Dynamic value (initial + updates) into the given ref so -out-of-band readers (the MCP server) can snapshot the live model. +{- | Invoke the given callback with every Dynamic value (initial + each +update) so out-of-band subscribers (like the MCP server) can observe model +changes without driving Ema's render loop. + +Phase 4 (#645) will grow this into a fanout: today the callback is a single +IORef write, but subscription-based resources will need per-subscriber +queues. When that lands, this helper and 'EmanoteConfig' should expose a +richer bus type rather than a single @Model -> IO ()@. -} -tapModelRef :: +tapModel :: (MonadIO m) => - IORef (Maybe Model.Model) -> + (Model.Model -> IO ()) -> Dynamic m Model.ModelEma -> m (Dynamic m Model.ModelEma) -tapModelRef ref (Dynamic (x0, updater)) = do - liftIO $ writeIORef ref $ Just (unModelEma x0) +tapModel onUpdate (Dynamic (x0, updater)) = do + liftIO $ onUpdate (unModelEma x0) pure $ Dynamic ( x0 , \send -> updater $ \x -> do - liftIO $ writeIORef ref $ Just (unModelEma x) + liftIO $ onUpdate (unModelEma x) send x ) @@ -100,7 +106,7 @@ run cfg@EmanoteConfig {..} = do in Ema.runSiteWith @SiteRoute emaCfg cfg >>= postRun cfg Just port -> do modelRef <- newIORef Nothing - let cfg' = cfg {_emanoteConfigLiveModelRef = Just modelRef} + let cfg' = cfg {_emanoteConfigOnModelUpdate = Just (writeIORef modelRef . Just)} emaCfg = SiteConfig (toEmaCli (CLI.Cmd_Run runCmd)) def ema = Ema.runSiteWith @SiteRoute emaCfg cfg' >>= postRun cfg' race_ (MCP.run port (CLI.verbose _emanoteConfigCli) modelRef) ema diff --git a/emanote/src/Emanote/MCP.hs b/emanote/src/Emanote/MCP.hs index b2acedeba..2c7c82ecf 100644 --- a/emanote/src/Emanote/MCP.hs +++ b/emanote/src/Emanote/MCP.hs @@ -12,7 +12,7 @@ notebook model as read-only MCP resources: * @emanote:\/\/note\/{path}@ — an individual note by its source path The live model is shared with 'Emanote.run' via an 'IORef' populated by -'Emanote.tapModelRef' on every Ema update. +'Emanote.tapModel' on every Ema update. -} module Emanote.MCP ( run, @@ -77,7 +77,7 @@ underlying @mcp@ library emits one line per request/response to stdout. Reads the current model from the supplied ref, which -'Emanote.tapModelRef' populates on every Ema update. When the ref is +'Emanote.tapModel' populates on every Ema update. When the ref is still 'Nothing' (a client arriving before Ema has produced its first model), handlers reply with JSON-RPC 503 so clients can retry. -} diff --git a/emanote/src/Emanote/Source/Dynamic.hs b/emanote/src/Emanote/Source/Dynamic.hs index 624b91202..f9db30350 100644 --- a/emanote/src/Emanote/Source/Dynamic.hs +++ b/emanote/src/Emanote/Source/Dynamic.hs @@ -6,8 +6,8 @@ module Emanote.Source.Dynamic ( EmanoteConfig (..), emanoteCompileTailwind, emanoteConfigCli, - emanoteConfigLiveModelRef, emanoteConfigNoteFn, + emanoteConfigOnModelUpdate, emanoteConfigPandocRenderers, ) where @@ -45,9 +45,12 @@ data EmanoteConfig = EmanoteConfig -- ^ How to render Pandoc to Heist HTML. , _emanoteCompileTailwind :: Bool -- ^ Whether to replace Tailwind2 CDN with a minimized Tailwind3 CSS file. - , _emanoteConfigLiveModelRef :: Maybe (IORef (Maybe Model.Model)) - -- ^ When set, each model update is mirrored to this ref. Used by the MCP - -- server to read the live model snapshot without driving Ema's render loop. + , _emanoteConfigOnModelUpdate :: Maybe (Model.Model -> IO ()) + -- ^ If set, called once with the initial model and again after every + -- update. Ema's 'Dynamic' is push-only (no pull-side API), so out-of-band + -- readers (the MCP server) subscribe via this hook rather than reaching + -- into Ema. Storage is the caller's concern; @Emanote.run@ writes to an + -- 'IORef' it owns. } {- | Make an Ema `Dynamic` for the Emanote model. From 7ba1a29556acd5dd5b243c5398be9a548cc30f1d Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Thu, 23 Apr 2026 16:45:23 -0400 Subject: [PATCH 04/26] refactor(lowy): document withModel 503 as startup-race artifact 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. --- emanote/src/Emanote/MCP.hs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/emanote/src/Emanote/MCP.hs b/emanote/src/Emanote/MCP.hs index 2c7c82ecf..a239cacda 100644 --- a/emanote/src/Emanote/MCP.hs +++ b/emanote/src/Emanote/MCP.hs @@ -169,7 +169,14 @@ handlers modelRef = } {- | Run the given action against the current model, or reply 503 if the model -ref is still empty (client arrived before Ema produced its first snapshot). +ref is still empty. + +'Nothing' here is always a startup race: @race_@ in 'Emanote.run' gives no +ordering guarantee between Warp's socket bind and Ema's first +'emanoteSiteInput' call, so a client can hit @/mcp@ before 'tapModel' has +written the initial snapshot. It is never a steady-state condition. Phase +4 (#645) should remove this path entirely by deferring 'MCP.run' until +after the first model is published. -} withModel :: IORef (Maybe Model) -> From 6449a8298dcfec8bdc18b7744ac706ec9c030e41 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Thu, 23 Apr 2026 16:50:56 -0400 Subject: [PATCH 05/26] =?UTF-8?q?refactor(police):=20elegance=20=E2=80=94?= =?UTF-8?q?=20extract=20readNoteResource=20from=20readResource?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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. --- emanote/src/Emanote/MCP.hs | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/emanote/src/Emanote/MCP.hs b/emanote/src/Emanote/MCP.hs index a239cacda..3878f6301 100644 --- a/emanote/src/Emanote/MCP.hs +++ b/emanote/src/Emanote/MCP.hs @@ -198,19 +198,22 @@ readResource model uri | uri == contentUri = do body <- liftIO $ ExportContent.renderContentExport model pure $ ProcessSuccess $ textResult uri (Just "text/markdown") body - | Just path <- T.stripPrefix noteUriPrefix uri - , Just route <- parseNoteRoute (toString path) = - case Note.lookupNotesByRoute route (model ^. M.modelNotes) of - Nothing -> pure $ ProcessRPCError 404 $ "Note not found: " <> uri - Just note -> do - mContent <- liftIO $ ExportContent.readNoteContent note - case mContent of - Nothing -> pure $ ProcessRPCError 404 $ "Note has no source file: " <> uri - Just content -> - let header = ExportContent.generateNoteHeader model note - in pure $ ProcessSuccess $ textResult uri (Just "text/markdown") (header <> content) + | Just path <- T.stripPrefix noteUriPrefix uri = + readNoteResource model uri (toString path) | otherwise = pure $ ProcessRPCError 404 $ "Resource not found: " <> uri +readNoteResource :: Model -> Text -> FilePath -> MCPServerT (ProcessResult ReadResourceResult) +readNoteResource model uri path = + case parseNoteRoute path >>= (`Note.lookupNotesByRoute` (model ^. M.modelNotes)) of + Nothing -> pure $ ProcessRPCError 404 $ "Note not found: " <> uri + Just note -> do + mContent <- liftIO $ ExportContent.readNoteContent note + case mContent of + Nothing -> pure $ ProcessRPCError 404 $ "Note has no source file: " <> uri + Just content -> + let header = ExportContent.generateNoteHeader model note + in pure $ ProcessSuccess $ textResult uri (Just "text/markdown") (header <> content) + parseNoteRoute :: FilePath -> Maybe R.LMLRoute parseNoteRoute fp = mkLMLRouteFromKnownFilePath Md fp <|> mkLMLRouteFromKnownFilePath Org fp From de0bb866925ce784ab2f9424d103b2f3f74193ac Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Thu, 23 Apr 2026 17:07:35 -0400 Subject: [PATCH 06/26] chore: wire emanote MCP server into just run + apm.yml '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. --- apm.yml | 6 +++++- justfile | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apm.yml b/apm.yml index a175169db..c9fc97ec8 100644 --- a/apm.yml +++ b/apm.yml @@ -8,5 +8,9 @@ dependencies: - srid/agency#master - juspay/skills/skills/nix-justfile - anthropics/skills/skills/frontend-design - mcp: [] + mcp: + - name: emanote + registry: false + transport: http + url: http://localhost:8079/mcp scripts: {} diff --git a/justfile b/justfile index 25d4ed373..f7b4fab2c 100644 --- a/justfile +++ b/justfile @@ -21,8 +21,10 @@ fmt: # Run the app using ghcid (with auto-reload / recompile) # To run against a custom notebook: # just notebook=$HOME/code/mynotebook run +# The MCP HTTP endpoint is available at http://localhost:8079/mcp for +# apm.yml's emanote client (see dependencies.mcp). run: - {{nix_shell}} ghcid -c 'cabal repl exe:emanote --flags=ghcid' --warnings -T ":main -L {{notebook}} run --port=9010" + {{nix_shell}} ghcid -c 'cabal repl exe:emanote --flags=ghcid' --warnings -T ":main -L {{notebook}} run --port=9010 --mcp-port=8079" # Run ghcid with log output to ghcid.log ghcid: From 7a6d6ca6b5c580ab14fd39130345c000adfe85c4 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Thu, 23 Apr 2026 17:19:14 -0400 Subject: [PATCH 07/26] chore: register emanote MCP server in .mcp.json Claude Code picks up emanote via 'just run' on http://localhost:8079/mcp. --- .mcp.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.mcp.json b/.mcp.json index bf5d3c14d..92e86fc69 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,6 +3,10 @@ "chrome-devtools": { "command": "just", "args": ["mcp-chrome-devtools"] + }, + "emanote": { + "type": "http", + "url": "http://localhost:8079/mcp" } } } From ff3b6407f058a90e9ddecdf38c46ee6a9505fcde Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Thu, 23 Apr 2026 21:29:42 -0400 Subject: [PATCH 08/26] refactor(mcp): use Ema.runSiteWithInput; drop LiveModel handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- emanote/src/Emanote.hs | 52 +++++++-------------- emanote/src/Emanote/MCP.hs | 66 ++++++++++----------------- emanote/src/Emanote/Source/Dynamic.hs | 7 --- flake.lock | 7 +-- flake.nix | 5 +- 5 files changed, 49 insertions(+), 88 deletions(-) diff --git a/emanote/src/Emanote.hs b/emanote/src/Emanote.hs index 80ef96e7b..2d3d26d89 100644 --- a/emanote/src/Emanote.hs +++ b/emanote/src/Emanote.hs @@ -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 @@ -55,36 +56,9 @@ instance EmaSite SiteRoute where type SiteArg SiteRoute = EmanoteConfig siteInput cliAct cfg = do model <- emanoteSiteInput cliAct cfg - let tapped = model <&> modelUpdateCachedFields - case _emanoteConfigOnModelUpdate cfg of - Nothing -> pure tapped - Just onUpdate -> tapModel onUpdate tapped + pure $ model <&> modelUpdateCachedFields siteOutput = View.emanoteSiteOutput -{- | Invoke the given callback with every Dynamic value (initial + each -update) so out-of-band subscribers (like the MCP server) can observe model -changes without driving Ema's render loop. - -Phase 4 (#645) will grow this into a fanout: today the callback is a single -IORef write, but subscription-based resources will need per-subscriber -queues. When that lands, this helper and 'EmanoteConfig' should expose a -richer bus type rather than a single @Model -> IO ()@. --} -tapModel :: - (MonadIO m) => - (Model.Model -> IO ()) -> - Dynamic m Model.ModelEma -> - m (Dynamic m Model.ModelEma) -tapModel onUpdate (Dynamic (x0, updater)) = do - liftIO $ onUpdate (unModelEma x0) - pure - $ Dynamic - ( x0 - , \send -> updater $ \x -> do - liftIO $ onUpdate (unModelEma x) - send x - ) - -- | Populate model fields that needs to be computed once per update. modelUpdateCachedFields :: Model.ModelEma -> Model.ModelEma modelUpdateCachedFields model = @@ -94,7 +68,12 @@ modelUpdateCachedFields model = defaultEmanoteConfig :: CLI.Cli -> EmanoteConfig defaultEmanoteConfig cli = - EmanoteConfig cli id defaultEmanotePandocRenderers False Nothing + EmanoteConfig + { _emanoteConfigCli = cli + , _emanoteConfigNoteFn = id + , _emanoteConfigPandocRenderers = defaultEmanotePandocRenderers + , _emanoteCompileTailwind = False + } run :: EmanoteConfig -> IO () run cfg@EmanoteConfig {..} = do @@ -105,11 +84,14 @@ run cfg@EmanoteConfig {..} = do let emaCfg = SiteConfig (toEmaCli (CLI.Cmd_Run runCmd)) def in Ema.runSiteWith @SiteRoute emaCfg cfg >>= postRun cfg Just port -> do - modelRef <- newIORef Nothing - let cfg' = cfg {_emanoteConfigOnModelUpdate = Just (writeIORef modelRef . Just)} - emaCfg = SiteConfig (toEmaCli (CLI.Cmd_Run runCmd)) def - ema = Ema.runSiteWith @SiteRoute emaCfg cfg' >>= postRun cfg' - race_ (MCP.run port (CLI.verbose _emanoteConfigCli) modelRef) ema + let emaCfg = SiteConfig (toEmaCli (CLI.Cmd_Run runCmd)) def + flip runLoggerLoggingT (Ema.CLI.getLogger (toEmaCli (CLI.Cmd_Run runCmd))) $ do + rawDyn <- siteInput @SiteRoute (Ema.CLI.action (toEmaCli (CLI.Cmd_Run runCmd))) cfg + (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 diff --git a/emanote/src/Emanote/MCP.hs b/emanote/src/Emanote/MCP.hs index 3878f6301..23761eca9 100644 --- a/emanote/src/Emanote/MCP.hs +++ b/emanote/src/Emanote/MCP.hs @@ -11,8 +11,9 @@ notebook model as read-only MCP resources: * @emanote:\/\/export\/content@ — all notes concatenated as a single Markdown document * @emanote:\/\/note\/{path}@ — an individual note by its source path -The live model is shared with 'Emanote.run' via an 'IORef' populated by -'Emanote.tapModel' on every Ema update. +The live model is read via an 'IO Model' supplied by 'Emanote.run', which +builds it from 'Ema.Dynamic.currentValue' on the 'Dynamic' produced by +the site's 'siteInput'. -} module Emanote.MCP ( run, @@ -76,16 +77,16 @@ to stderr once Warp has bound the socket. When @verbose@ is set, the underlying @mcp@ library emits one line per request/response to stdout. -Reads the current model from the supplied ref, which -'Emanote.tapModel' populates on every Ema update. When the ref is -still 'Nothing' (a client arriving before Ema has produced its first -model), handlers reply with JSON-RPC 503 so clients can retry. +The model reader is produced by 'Emanote.run' on top of +'Ema.Dynamic.currentValue'. It returns the initial model before any +update lands, and the latest pushed value thereafter — both reads are +non-blocking pointer loads. -} -run :: Int -> Bool -> IORef (Maybe Model) -> IO () -run port verbose modelRef = do +run :: Int -> Bool -> IO Model -> IO () +run port verbose readModel = do stateVar <- newMVar - (initMCPServerState () Nothing Nothing capabilities implementation instructions (handlers modelRef)) + (initMCPServerState () Nothing Nothing capabilities implementation instructions (handlers readModel)) { mcp_log_level = Just (if verbose then Debug else Warning) } let settings = @@ -143,19 +144,19 @@ noteUriTemplate = noteUriPrefix <> "{path}" noteUri :: R.LMLRoute -> Text noteUri route = noteUriPrefix <> toText (ExportJSON.lmlSourcePath route) -handlers :: IORef (Maybe Model) -> MCP.ProcessHandlers -handlers modelRef = +handlers :: IO Model -> MCP.ProcessHandlers +handlers readModel = withToolHandlers [] $ defaultProcessHandlers - { listResourcesHandler = Just $ \_ -> - withModel modelRef $ \model -> - pure - $ ProcessSuccess - $ ListResourcesResult - { resources = staticResources <> noteResources model - , nextCursor = Nothing - , MCP._meta = Nothing - } + { listResourcesHandler = Just $ \_ -> do + model <- liftIO readModel + pure + $ ProcessSuccess + $ ListResourcesResult + { resources = staticResources <> noteResources model + , nextCursor = Nothing + , MCP._meta = Nothing + } , listResourceTemplatesHandler = Just $ \_ -> pure $ ProcessSuccess @@ -164,30 +165,11 @@ handlers modelRef = , nextCursor = Nothing , MCP._meta = Nothing } - , readResourceHandler = Just $ \ReadResourceParams {uri} -> - withModel modelRef $ \model -> readResource model uri + , readResourceHandler = Just $ \ReadResourceParams {uri} -> do + model <- liftIO readModel + readResource model uri } -{- | Run the given action against the current model, or reply 503 if the model -ref is still empty. - -'Nothing' here is always a startup race: @race_@ in 'Emanote.run' gives no -ordering guarantee between Warp's socket bind and Ema's first -'emanoteSiteInput' call, so a client can hit @/mcp@ before 'tapModel' has -written the initial snapshot. It is never a steady-state condition. Phase -4 (#645) should remove this path entirely by deferring 'MCP.run' until -after the first model is published. --} -withModel :: - IORef (Maybe Model) -> - (Model -> MCPServerT (ProcessResult a)) -> - MCPServerT (ProcessResult a) -withModel ref k = do - mModel <- liftIO $ readIORef ref - case mModel of - Nothing -> pure $ ProcessRPCError 503 "Emanote model not yet loaded; please retry" - Just model -> k model - readResource :: Model -> Text -> MCPServerT (ProcessResult ReadResourceResult) readResource model uri | uri == metadataUri = diff --git a/emanote/src/Emanote/Source/Dynamic.hs b/emanote/src/Emanote/Source/Dynamic.hs index f9db30350..53c2ffac4 100644 --- a/emanote/src/Emanote/Source/Dynamic.hs +++ b/emanote/src/Emanote/Source/Dynamic.hs @@ -7,7 +7,6 @@ module Emanote.Source.Dynamic ( emanoteCompileTailwind, emanoteConfigCli, emanoteConfigNoteFn, - emanoteConfigOnModelUpdate, emanoteConfigPandocRenderers, ) where @@ -45,12 +44,6 @@ data EmanoteConfig = EmanoteConfig -- ^ How to render Pandoc to Heist HTML. , _emanoteCompileTailwind :: Bool -- ^ Whether to replace Tailwind2 CDN with a minimized Tailwind3 CSS file. - , _emanoteConfigOnModelUpdate :: Maybe (Model.Model -> IO ()) - -- ^ If set, called once with the initial model and again after every - -- update. Ema's 'Dynamic' is push-only (no pull-side API), so out-of-band - -- readers (the MCP server) subscribe via this hook rather than reaching - -- into Ema. Storage is the caller's concern; @Emanote.run@ writes to an - -- 'IORef' it owns. } {- | Make an Ema `Dynamic` for the Emanote model. diff --git a/flake.lock b/flake.lock index 98a6af18b..7bab80404 100644 --- a/flake.lock +++ b/flake.lock @@ -71,15 +71,16 @@ "ema": { "flake": false, "locked": { - "lastModified": 1776974157, - "narHash": "sha256-rMb1a7VzMS2nmfEWAtJEKgZYg9Mc5JdSNK4y+z1S/ec=", + "lastModified": 1776992687, + "narHash": "sha256-jco2gkKfR/1szDgNpe5g4aqW7e9B3ZdKl8RZq5UCpDk=", "owner": "srid", "repo": "ema", - "rev": "e92e52dbefea57de08ef64b4db0ea795170d6b81", + "rev": "788de7f42307226d8238971d590f766df5e88e33", "type": "github" }, "original": { "owner": "srid", + "ref": "feat/run-site-with-input", "repo": "ema", "type": "github" } diff --git a/flake.nix b/flake.nix index 4cc3ecde5..e4f4d4f64 100644 --- a/flake.nix +++ b/flake.nix @@ -15,7 +15,10 @@ nixos-unified.url = "github:srid/nixos-unified"; # These are not (necessarily) upstreamed to nixpkgs, yet. - ema.url = "github:srid/ema"; + # Temporarily pinned to the feat/run-site-with-input branch until + # srid/ema#179 merges to master. Brings in currentValue (#177) plus + # runSiteWithInput. + ema.url = "github:srid/ema/feat/run-site-with-input"; ema.flake = false; lvar.url = "github:srid/lvar/0.2.0.0"; lvar.flake = false; From 56007be521520d4cc056a2fb2ec6bab42a33cfb8 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Fri, 24 Apr 2026 14:45:11 -0400 Subject: [PATCH 09/26] chore(flake): bump ema input to master srid/ema#179 landed; drop the branch pin. --- flake.lock | 7 +++---- flake.nix | 5 +---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/flake.lock b/flake.lock index 7bab80404..763294827 100644 --- a/flake.lock +++ b/flake.lock @@ -71,16 +71,15 @@ "ema": { "flake": false, "locked": { - "lastModified": 1776992687, - "narHash": "sha256-jco2gkKfR/1szDgNpe5g4aqW7e9B3ZdKl8RZq5UCpDk=", + "lastModified": 1777055353, + "narHash": "sha256-9O6C7Z6gcCiay3LEdXiEru9pPmnGVHkAjlgt5+j4cM8=", "owner": "srid", "repo": "ema", - "rev": "788de7f42307226d8238971d590f766df5e88e33", + "rev": "5dc98749dc0e132306c806d7c1d7fa1b4aafb667", "type": "github" }, "original": { "owner": "srid", - "ref": "feat/run-site-with-input", "repo": "ema", "type": "github" } diff --git a/flake.nix b/flake.nix index e4f4d4f64..4cc3ecde5 100644 --- a/flake.nix +++ b/flake.nix @@ -15,10 +15,7 @@ nixos-unified.url = "github:srid/nixos-unified"; # These are not (necessarily) upstreamed to nixpkgs, yet. - # Temporarily pinned to the feat/run-site-with-input branch until - # srid/ema#179 merges to master. Brings in currentValue (#177) plus - # runSiteWithInput. - ema.url = "github:srid/ema/feat/run-site-with-input"; + ema.url = "github:srid/ema"; ema.flake = false; lvar.url = "github:srid/lvar/0.2.0.0"; lvar.flake = false; From e442737c76186f7e37f6f3bcadb83d2297fcc06e Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Fri, 24 Apr 2026 14:46:12 -0400 Subject: [PATCH 10/26] refactor(emanote): unify Run branches on runSiteWithInput 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. --- emanote/CHANGELOG.md | 2 +- emanote/src/Emanote.hs | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/emanote/CHANGELOG.md b/emanote/CHANGELOG.md index cf14275dc..3ae1a81c5 100644 --- a/emanote/CHANGELOG.md +++ b/emanote/CHANGELOG.md @@ -4,7 +4,7 @@ **Notable features** -- **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), `emanote://export/content` (single-file Markdown dump), and `emanote://note/{path}` for individual notes. Query tools and subscriptions 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), `emanote://export/content` (single-file Markdown dump), and `emanote://note/{path}` for individual notes. Query tools and subscriptions follow in later phases ([#645](https://github.com/srid/emanote/issues/645), [#649](https://github.com/srid/emanote/pull/649)) - **Tailwind v3 → v4 migration** with CSS-variable design tokens ([#633](https://github.com/srid/emanote/pull/633)) - Built-in static syntax highlighting using skylighting, replacing client-side JS highlighters ([#624](https://github.com/srid/emanote/pull/624)) - Built-in static math rendering (LaTeX → MathML at build time via `texmath`) ([#639](https://github.com/srid/emanote/pull/639)) diff --git a/emanote/src/Emanote.hs b/emanote/src/Emanote.hs index 2d3d26d89..5917d8335 100644 --- a/emanote/src/Emanote.hs +++ b/emanote/src/Emanote.hs @@ -79,14 +79,14 @@ run :: EmanoteConfig -> IO () run cfg@EmanoteConfig {..} = do case CLI.cmd _emanoteConfigCli of CLI.Cmd_Run runCmd -> do - case CLI.runMcpPort runCmd of - Nothing -> - let emaCfg = SiteConfig (toEmaCli (CLI.Cmd_Run runCmd)) def - in Ema.runSiteWith @SiteRoute emaCfg cfg >>= postRun cfg - Just port -> do - let emaCfg = SiteConfig (toEmaCli (CLI.Cmd_Run runCmd)) def - flip runLoggerLoggingT (Ema.CLI.getLogger (toEmaCli (CLI.Cmd_Run runCmd))) $ do - rawDyn <- siteInput @SiteRoute (Ema.CLI.action (toEmaCli (CLI.Cmd_Run runCmd))) cfg + 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 (readEma, wrapped) <- currentValue rawDyn let readLiveModel = unModelEma <$> readEma race_ From d7470d729f5c4b0d3b0a44d2f19baf64862b4be7 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Fri, 24 Apr 2026 14:53:08 -0400 Subject: [PATCH 11/26] refactor(mcp): extract notebook resource catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- emanote/emanote.cabal | 1 + emanote/src/Emanote/MCP.hs | 157 ++++++++------------- emanote/src/Emanote/View/Export/Catalog.hs | 120 ++++++++++++++++ 3 files changed, 181 insertions(+), 97 deletions(-) create mode 100644 emanote/src/Emanote/View/Export/Catalog.hs diff --git a/emanote/emanote.cabal b/emanote/emanote.cabal index 5ba3e8bb3..37891fadf 100644 --- a/emanote/emanote.cabal +++ b/emanote/emanote.cabal @@ -209,6 +209,7 @@ library Emanote.View Emanote.View.Common Emanote.View.Export + Emanote.View.Export.Catalog Emanote.View.Export.Content Emanote.View.Export.JSON Emanote.View.Feed diff --git a/emanote/src/Emanote/MCP.hs b/emanote/src/Emanote/MCP.hs index 23761eca9..2ba962547 100644 --- a/emanote/src/Emanote/MCP.hs +++ b/emanote/src/Emanote/MCP.hs @@ -11,9 +11,15 @@ notebook model as read-only MCP resources: * @emanote:\/\/export\/content@ — all notes concatenated as a single Markdown document * @emanote:\/\/note\/{path}@ — an individual note by its source path -The live model is read via an 'IO Model' supplied by 'Emanote.run', which -builds it from 'Ema.Dynamic.currentValue' on the 'Dynamic' produced by -the site's 'siteInput'. +This module owns the MCP /protocol surface/ only — URI constants, +capability declarations, handshake text, and Resource/ResourceTemplate +wire types. The /notebook catalog/ (what's available, how to read it) +lives in 'Emanote.View.Export.Catalog'. The two are bridged by +'uriToKind' \/ 'kindToUri' and 'toMcpResource'. + +The live model is read via an 'IO Model' supplied by 'Emanote.run', +which builds it from 'Ema.Dynamic.currentValue' on the 'Dynamic' +produced by the site's 'siteInput'. -} module Emanote.MCP ( run, @@ -22,14 +28,8 @@ module Emanote.MCP ( import Data.Text qualified as T import Data.Version (showVersion) import Emanote.Model (Model) -import Emanote.Model qualified as M -import Emanote.Model.Note qualified as Note -import Emanote.Model.Title qualified as Tit -import Emanote.Route qualified as R -import Emanote.Route.Ext (LML (Md, Org)) -import Emanote.Route.ModelRoute (mkLMLRouteFromKnownFilePath) -import Emanote.View.Export.Content qualified as ExportContent -import Emanote.View.Export.JSON qualified as ExportJSON +import Emanote.View.Export.Catalog (NotebookResource (..), ResourceBody (..), ResourceKind (..)) +import Emanote.View.Export.Catalog qualified as Catalog import MCP.Server ( Implementation (..), ListResourceTemplatesResult (..), @@ -38,7 +38,6 @@ import MCP.Server ( MCPHandlerState, MCPHandlerUser, MCPServerState (..), - MCPServerT, ProcessResult (..), ReadResourceParams (..), ReadResourceResult (..), @@ -59,7 +58,6 @@ import MCP.Server ( ) import MCP.Server qualified as MCP import Network.Wai.Handler.Warp qualified as Warp -import Optics.Operators ((^.)) import Paths_emanote qualified import Relude import System.IO (hPutStrLn) @@ -77,10 +75,10 @@ to stderr once Warp has bound the socket. When @verbose@ is set, the underlying @mcp@ library emits one line per request/response to stdout. -The model reader is produced by 'Emanote.run' on top of -'Ema.Dynamic.currentValue'. It returns the initial model before any -update lands, and the latest pushed value thereafter — both reads are -non-blocking pointer loads. +The @'IO' 'Model'@ reader must be non-blocking — handlers call it +synchronously from the request path. 'Ema.Dynamic.currentValue' +satisfies this (it reads an 'IORef' seeded with the Dynamic's initial +value before the reader is returned), and is the intended source. -} run :: Int -> Bool -> IO Model -> IO () run port verbose readModel = do @@ -126,6 +124,8 @@ capabilities = , experimental = Nothing } +-- * URI schema (wire contract — external clients hard-code these) + metadataUri :: Text metadataUri = "emanote://export/metadata" @@ -135,14 +135,39 @@ contentUri = "emanote://export/content" noteUriPrefix :: Text noteUriPrefix = "emanote://note/" -{- | RFC 6570 template for the per-note URI; referenced both in instructions -and in 'noteTemplate'. --} +-- | RFC 6570 template for the per-note URI. noteUriTemplate :: Text noteUriTemplate = noteUriPrefix <> "{path}" -noteUri :: R.LMLRoute -> Text -noteUri route = noteUriPrefix <> toText (ExportJSON.lmlSourcePath route) +-- * Catalog ↔ URI translation + +uriToKind :: Text -> Maybe ResourceKind +uriToKind uri + | uri == metadataUri = Just MetadataJson + | uri == contentUri = Just ContentMarkdown + | Just path <- T.stripPrefix noteUriPrefix uri = Just (Note (toString path)) + | otherwise = Nothing + +kindToUri :: ResourceKind -> Text +kindToUri = \case + MetadataJson -> metadataUri + ContentMarkdown -> contentUri + Note path -> noteUriPrefix <> toText path + +toMcpResource :: NotebookResource -> Resource +toMcpResource NotebookResource {resourceKind, resourceName, resourceTitle, resourceMime, resourceDescription} = + Resource + { MCP.uri = kindToUri resourceKind + , MCP.name = resourceName + , MCP.title = resourceTitle + , MCP.description = resourceDescription + , MCP.mimeType = Just resourceMime + , size = Nothing + , annotations = Nothing + , MCP._meta = Nothing + } + +-- * Handlers handlers :: IO Model -> MCP.ProcessHandlers handlers readModel = @@ -153,7 +178,7 @@ handlers readModel = pure $ ProcessSuccess $ ListResourcesResult - { resources = staticResources <> noteResources model + { resources = toMcpResource <$> Catalog.listResources model , nextCursor = Nothing , MCP._meta = Nothing } @@ -165,42 +190,19 @@ handlers readModel = , nextCursor = Nothing , MCP._meta = Nothing } - , readResourceHandler = Just $ \ReadResourceParams {uri} -> do - model <- liftIO readModel - readResource model uri + , readResourceHandler = Just $ \ReadResourceParams {uri} -> + case uriToKind uri of + Nothing -> pure $ ProcessRPCError 404 $ "Resource not found: " <> uri + Just kind -> do + model <- liftIO readModel + mBody <- liftIO $ Catalog.readResource model kind + pure $ case mBody of + Nothing -> ProcessRPCError 404 $ "Resource not found: " <> uri + Just (ResourceBody mime body) -> + ProcessSuccess $ textResult uri mime body } -readResource :: Model -> Text -> MCPServerT (ProcessResult ReadResourceResult) -readResource model uri - | uri == metadataUri = - pure - $ ProcessSuccess - $ textResult uri (Just "application/json") - $ decodeUtf8 (ExportJSON.renderJSONExport model) - | uri == contentUri = do - body <- liftIO $ ExportContent.renderContentExport model - pure $ ProcessSuccess $ textResult uri (Just "text/markdown") body - | Just path <- T.stripPrefix noteUriPrefix uri = - readNoteResource model uri (toString path) - | otherwise = pure $ ProcessRPCError 404 $ "Resource not found: " <> uri - -readNoteResource :: Model -> Text -> FilePath -> MCPServerT (ProcessResult ReadResourceResult) -readNoteResource model uri path = - case parseNoteRoute path >>= (`Note.lookupNotesByRoute` (model ^. M.modelNotes)) of - Nothing -> pure $ ProcessRPCError 404 $ "Note not found: " <> uri - Just note -> do - mContent <- liftIO $ ExportContent.readNoteContent note - case mContent of - Nothing -> pure $ ProcessRPCError 404 $ "Note has no source file: " <> uri - Just content -> - let header = ExportContent.generateNoteHeader model note - in pure $ ProcessSuccess $ textResult uri (Just "text/markdown") (header <> content) - -parseNoteRoute :: FilePath -> Maybe R.LMLRoute -parseNoteRoute fp = - mkLMLRouteFromKnownFilePath Md fp <|> mkLMLRouteFromKnownFilePath Org fp - -textResult :: Text -> Maybe Text -> Text -> ReadResourceResult +textResult :: Text -> Text -> Text -> ReadResourceResult textResult uri mime body = ReadResourceResult { contents = @@ -208,52 +210,13 @@ textResult uri mime body = TextResourceContents { MCP.uri = uri , text = body - , mimeType = mime + , mimeType = Just mime , MCP._meta = Nothing } ] , MCP._meta = Nothing } -staticResources :: [Resource] -staticResources = - [ Resource - { MCP.uri = metadataUri - , MCP.name = "Notebook metadata" - , MCP.title = Just "Notebook metadata (JSON)" - , MCP.description = Just "Notebook metadata as JSON: per-note titles, source paths, parent routes, and resolved links." - , MCP.mimeType = Just "application/json" - , size = Nothing - , annotations = Nothing - , MCP._meta = Nothing - } - , Resource - { MCP.uri = contentUri - , MCP.name = "Notebook content (single-file)" - , MCP.title = Just "Notebook content (single-file Markdown)" - , MCP.description = Just "All notes concatenated into a single Markdown document, separated by '===' delimiters." - , MCP.mimeType = Just "text/markdown" - , size = Nothing - , annotations = Nothing - , MCP._meta = Nothing - } - ] - -noteResources :: Model -> [Resource] -noteResources model = - [ Resource - { MCP.uri = noteUri (Note._noteRoute note) - , MCP.name = toText (ExportJSON.lmlSourcePath (Note._noteRoute note)) - , MCP.title = Just $ Tit.toPlain (Note._noteTitle note) - , MCP.description = Nothing - , MCP.mimeType = Just "text/markdown" - , size = Nothing - , annotations = Nothing - , MCP._meta = Nothing - } - | note <- toList (model ^. M.modelNotes) - ] - noteTemplate :: ResourceTemplate noteTemplate = ResourceTemplate diff --git a/emanote/src/Emanote/View/Export/Catalog.hs b/emanote/src/Emanote/View/Export/Catalog.hs new file mode 100644 index 000000000..bd527bbf2 --- /dev/null +++ b/emanote/src/Emanote/View/Export/Catalog.hs @@ -0,0 +1,120 @@ +{- | Protocol-agnostic catalog of what an Emanote notebook exposes. + +Sits between 'Emanote.Model' and any surface that wants to publish the +notebook (MCP today, possibly other transports later). Knows nothing +about MCP, URIs, or any wire format — the consumer translates +'ResourceKind' into its own addressing scheme. + +The catalog answers two questions: + +* /What/ is available? — 'listResources' returns catalog entries, one per + static export ('MetadataJson', 'ContentMarkdown') and one per note. +* /How do I fetch one?/ — 'readResource' resolves a 'ResourceKind' to a + 'ResourceBody'. +-} +module Emanote.View.Export.Catalog ( + ResourceKind (..), + NotebookResource (..), + ResourceBody (..), + listResources, + readResource, +) where + +import Emanote.Model (Model) +import Emanote.Model qualified as M +import Emanote.Model.Note qualified as Note +import Emanote.Model.Title qualified as Tit +import Emanote.Route qualified as R +import Emanote.Route.Ext (LML (Md, Org)) +import Emanote.Route.ModelRoute (mkLMLRouteFromKnownFilePath) +import Emanote.View.Export.Content qualified as ExportContent +import Emanote.View.Export.JSON qualified as ExportJSON +import Optics.Operators ((^.)) +import Relude + +-- | A kind of resource the notebook exposes. +data ResourceKind + = -- | Whole-notebook metadata as JSON. + MetadataJson + | -- | Whole-notebook concatenated Markdown. + ContentMarkdown + | -- | Individual note by source-relative path (e.g. @guide/mcp.md@). + Note FilePath + deriving stock (Show, Eq) + +-- | Catalog entry. URI-free by design; consumers assign addressing. +data NotebookResource = NotebookResource + { resourceKind :: ResourceKind + , resourceName :: Text + , resourceTitle :: Maybe Text + , resourceMime :: Text + , resourceDescription :: Maybe Text + } + +-- | Body payload for a resolved resource. +data ResourceBody = ResourceBody + { resourceBodyMime :: Text + , resourceBodyText :: Text + } + +-- | Enumerate all resources the notebook currently exposes. +listResources :: Model -> [NotebookResource] +listResources model = staticResources <> noteResources model + +staticResources :: [NotebookResource] +staticResources = + [ NotebookResource + { resourceKind = MetadataJson + , resourceName = "Notebook metadata" + , resourceTitle = Just "Notebook metadata (JSON)" + , resourceMime = "application/json" + , resourceDescription = Just "Notebook metadata as JSON: per-note titles, source paths, parent routes, and resolved links." + } + , NotebookResource + { resourceKind = ContentMarkdown + , resourceName = "Notebook content (single-file)" + , resourceTitle = Just "Notebook content (single-file Markdown)" + , resourceMime = "text/markdown" + , resourceDescription = Just "All notes concatenated into a single Markdown document, separated by '===' delimiters." + } + ] + +noteResources :: Model -> [NotebookResource] +noteResources model = + [ NotebookResource + { resourceKind = Note sourcePath + , resourceName = toText sourcePath + , resourceTitle = Just (Tit.toPlain (Note._noteTitle note)) + , resourceMime = "text/markdown" + , resourceDescription = Nothing + } + | note <- toList (model ^. M.modelNotes) + , let sourcePath = ExportJSON.lmlSourcePath (Note._noteRoute note) + ] + +{- | Resolve a 'ResourceKind' to its body. + +Returns 'Nothing' when a 'Note' kind references a path that doesn't +correspond to any known note, or when the note has no source file +(auto-generated notes). +-} +readResource :: Model -> ResourceKind -> IO (Maybe ResourceBody) +readResource model = \case + MetadataJson -> + pure $ Just $ ResourceBody "application/json" (decodeUtf8 (ExportJSON.renderJSONExport model)) + ContentMarkdown -> do + body <- ExportContent.renderContentExport model + pure $ Just $ ResourceBody "text/markdown" body + Note path -> + case parseNoteRoute path >>= (`Note.lookupNotesByRoute` (model ^. M.modelNotes)) of + Nothing -> pure Nothing + Just note -> do + mContent <- ExportContent.readNoteContent note + pure $ do + content <- mContent + let header = ExportContent.generateNoteHeader model note + Just $ ResourceBody "text/markdown" (header <> content) + +parseNoteRoute :: FilePath -> Maybe R.LMLRoute +parseNoteRoute fp = + mkLMLRouteFromKnownFilePath Md fp <|> mkLMLRouteFromKnownFilePath Org fp From 7ab03670603dca69c6aa067dff3fa352e37f4b90 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Fri, 24 Apr 2026 15:11:58 -0400 Subject: [PATCH 12/26] refactor(mcp): colocate Catalog under Emanote.MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/emanote.cabal | 2 +- emanote/src/Emanote/MCP.hs | 6 +++--- .../src/Emanote/{View/Export => MCP}/Catalog.hs | 16 ++++++++-------- 3 files changed, 12 insertions(+), 12 deletions(-) rename emanote/src/Emanote/{View/Export => MCP}/Catalog.hs (90%) diff --git a/emanote/emanote.cabal b/emanote/emanote.cabal index 37891fadf..463dea5d3 100644 --- a/emanote/emanote.cabal +++ b/emanote/emanote.cabal @@ -165,6 +165,7 @@ library Emanote Emanote.CLI Emanote.MCP + Emanote.MCP.Catalog Emanote.Model Emanote.Model.Calendar Emanote.Model.Calendar.Parser @@ -209,7 +210,6 @@ library Emanote.View Emanote.View.Common Emanote.View.Export - Emanote.View.Export.Catalog Emanote.View.Export.Content Emanote.View.Export.JSON Emanote.View.Feed diff --git a/emanote/src/Emanote/MCP.hs b/emanote/src/Emanote/MCP.hs index 2ba962547..e8e3e1330 100644 --- a/emanote/src/Emanote/MCP.hs +++ b/emanote/src/Emanote/MCP.hs @@ -14,7 +14,7 @@ notebook model as read-only MCP resources: This module owns the MCP /protocol surface/ only — URI constants, capability declarations, handshake text, and Resource/ResourceTemplate wire types. The /notebook catalog/ (what's available, how to read it) -lives in 'Emanote.View.Export.Catalog'. The two are bridged by +lives in "Emanote.MCP.Catalog". The two are bridged by 'uriToKind' \/ 'kindToUri' and 'toMcpResource'. The live model is read via an 'IO Model' supplied by 'Emanote.run', @@ -27,9 +27,9 @@ module Emanote.MCP ( import Data.Text qualified as T import Data.Version (showVersion) +import Emanote.MCP.Catalog (NotebookResource (..), ResourceBody (..), ResourceKind (..)) +import Emanote.MCP.Catalog qualified as Catalog import Emanote.Model (Model) -import Emanote.View.Export.Catalog (NotebookResource (..), ResourceBody (..), ResourceKind (..)) -import Emanote.View.Export.Catalog qualified as Catalog import MCP.Server ( Implementation (..), ListResourceTemplatesResult (..), diff --git a/emanote/src/Emanote/View/Export/Catalog.hs b/emanote/src/Emanote/MCP/Catalog.hs similarity index 90% rename from emanote/src/Emanote/View/Export/Catalog.hs rename to emanote/src/Emanote/MCP/Catalog.hs index bd527bbf2..1292845bc 100644 --- a/emanote/src/Emanote/View/Export/Catalog.hs +++ b/emanote/src/Emanote/MCP/Catalog.hs @@ -1,18 +1,18 @@ -{- | Protocol-agnostic catalog of what an Emanote notebook exposes. +{- | Notebook resource catalog consumed by "Emanote.MCP". -Sits between 'Emanote.Model' and any surface that wants to publish the -notebook (MCP today, possibly other transports later). Knows nothing -about MCP, URIs, or any wire format — the consumer translates -'ResourceKind' into its own addressing scheme. - -The catalog answers two questions: +Answers two questions: * /What/ is available? — 'listResources' returns catalog entries, one per static export ('MetadataJson', 'ContentMarkdown') and one per note. * /How do I fetch one?/ — 'readResource' resolves a 'ResourceKind' to a 'ResourceBody'. + +The types here are MCP-independent (no URIs, no wire types), which +keeps the catalog easy to reuse if a second surface ever appears. The +module lives under "Emanote.MCP" because MCP is today's only consumer +and shares the catalog's change cadence. -} -module Emanote.View.Export.Catalog ( +module Emanote.MCP.Catalog ( ResourceKind (..), NotebookResource (..), ResourceBody (..), From 8abdba3358a68fcfba19d9489eee4edfb83a5e32 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Fri, 24 Apr 2026 15:40:30 -0400 Subject: [PATCH 13/26] refactor(mcp): split MCP into Uri/Handlers/Server submodules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- emanote/emanote.cabal | 3 + emanote/src/Emanote/MCP.hs | 226 +--------------------------- emanote/src/Emanote/MCP/Handlers.hs | 117 ++++++++++++++ emanote/src/Emanote/MCP/Server.hs | 88 +++++++++++ emanote/src/Emanote/MCP/Uri.hs | 44 ++++++ 5 files changed, 260 insertions(+), 218 deletions(-) create mode 100644 emanote/src/Emanote/MCP/Handlers.hs create mode 100644 emanote/src/Emanote/MCP/Server.hs create mode 100644 emanote/src/Emanote/MCP/Uri.hs diff --git a/emanote/emanote.cabal b/emanote/emanote.cabal index 463dea5d3..bb68eafec 100644 --- a/emanote/emanote.cabal +++ b/emanote/emanote.cabal @@ -166,6 +166,9 @@ library Emanote.CLI Emanote.MCP Emanote.MCP.Catalog + Emanote.MCP.Handlers + Emanote.MCP.Server + Emanote.MCP.Uri Emanote.Model Emanote.Model.Calendar Emanote.Model.Calendar.Parser diff --git a/emanote/src/Emanote/MCP.hs b/emanote/src/Emanote/MCP.hs index e8e3e1330..3c30ea393 100644 --- a/emanote/src/Emanote/MCP.hs +++ b/emanote/src/Emanote/MCP.hs @@ -1,230 +1,20 @@ -{-# LANGUAGE DuplicateRecordFields #-} -{-# LANGUAGE NamedFieldPuns #-} -{-# OPTIONS_GHC -Wno-orphans #-} +{- | MCP (Model Context Protocol) server for Emanote. -{- | MCP (Model Context Protocol) server. - -Runs alongside the Emanote live server in the same process, exposing the -notebook model as read-only MCP resources: +Exposes the notebook model as read-only MCP resources: * @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 -This module owns the MCP /protocol surface/ only — URI constants, -capability declarations, handshake text, and Resource/ResourceTemplate -wire types. The /notebook catalog/ (what's available, how to read it) -lives in "Emanote.MCP.Catalog". The two are bridged by -'uriToKind' \/ 'kindToUri' and 'toMcpResource'. +Umbrella module. Implementation lives in: -The live model is read via an 'IO Model' supplied by 'Emanote.run', -which builds it from 'Ema.Dynamic.currentValue' on the 'Dynamic' -produced by the site's 'siteInput'. +* "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.Text qualified as T -import Data.Version (showVersion) -import Emanote.MCP.Catalog (NotebookResource (..), ResourceBody (..), ResourceKind (..)) -import Emanote.MCP.Catalog qualified as Catalog -import Emanote.Model (Model) -import MCP.Server ( - Implementation (..), - ListResourceTemplatesResult (..), - ListResourcesResult (..), - LoggingLevel (..), - MCPHandlerState, - MCPHandlerUser, - MCPServerState (..), - ProcessResult (..), - ReadResourceParams (..), - ReadResourceResult (..), - Resource (..), - ResourceContents (..), - ResourceTemplate (..), - ResourcesCapability (..), - ServerCapabilities (..), - TextResourceContents (..), - ToolsCapability (..), - defaultProcessHandlers, - initMCPServerState, - listResourceTemplatesHandler, - 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. - -The @'IO' 'Model'@ reader must be non-blocking — handlers call it -synchronously from the request path. 'Ema.Dynamic.currentValue' -satisfies this (it reads an 'IORef' seeded with the Dynamic's initial -value before the reader is returned), and is the intended source. --} -run :: Int -> Bool -> IO Model -> IO () -run port verbose readModel = do - stateVar <- - newMVar - (initMCPServerState () Nothing Nothing capabilities implementation instructions (handlers readModel)) - { 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 - $ unlines - [ "Emanote notebook exposed over MCP." - , "Resources:" - , "- " <> metadataUri <> " — JSON metadata for every note (titles, paths, parents, links)" - , "- " <> contentUri <> " — all notes concatenated as a single Markdown document" - , "- " <> noteUriTemplate <> " — individual note by source path (e.g. " <> noteUriPrefix <> "guide/mcp.md)" - ] - -capabilities :: ServerCapabilities -capabilities = - ServerCapabilities - { logging = Nothing - , prompts = Nothing - , resources = Just ResourcesCapability {listChanged = Nothing, subscribe = Nothing} - , tools = Just ToolsCapability {listChanged = Nothing} - , completions = Nothing - , experimental = Nothing - } - --- * URI schema (wire contract — external clients hard-code these) - -metadataUri :: Text -metadataUri = "emanote://export/metadata" - -contentUri :: Text -contentUri = "emanote://export/content" - -noteUriPrefix :: Text -noteUriPrefix = "emanote://note/" - --- | RFC 6570 template for the per-note URI. -noteUriTemplate :: Text -noteUriTemplate = noteUriPrefix <> "{path}" - --- * Catalog ↔ URI translation - -uriToKind :: Text -> Maybe ResourceKind -uriToKind uri - | uri == metadataUri = Just MetadataJson - | uri == contentUri = Just ContentMarkdown - | Just path <- T.stripPrefix noteUriPrefix uri = Just (Note (toString path)) - | otherwise = Nothing - -kindToUri :: ResourceKind -> Text -kindToUri = \case - MetadataJson -> metadataUri - ContentMarkdown -> contentUri - Note path -> noteUriPrefix <> toText path - -toMcpResource :: NotebookResource -> Resource -toMcpResource NotebookResource {resourceKind, resourceName, resourceTitle, resourceMime, resourceDescription} = - Resource - { MCP.uri = kindToUri resourceKind - , MCP.name = resourceName - , MCP.title = resourceTitle - , MCP.description = resourceDescription - , MCP.mimeType = Just resourceMime - , size = Nothing - , annotations = Nothing - , MCP._meta = Nothing - } - --- * Handlers - -handlers :: IO Model -> MCP.ProcessHandlers -handlers readModel = - withToolHandlers [] - $ defaultProcessHandlers - { listResourcesHandler = Just $ \_ -> do - model <- liftIO readModel - pure - $ ProcessSuccess - $ ListResourcesResult - { resources = toMcpResource <$> Catalog.listResources model - , nextCursor = Nothing - , MCP._meta = Nothing - } - , listResourceTemplatesHandler = Just $ \_ -> - pure - $ ProcessSuccess - $ ListResourceTemplatesResult - { resourceTemplates = [noteTemplate] - , nextCursor = Nothing - , MCP._meta = Nothing - } - , readResourceHandler = Just $ \ReadResourceParams {uri} -> - case uriToKind uri of - Nothing -> pure $ ProcessRPCError 404 $ "Resource not found: " <> uri - Just kind -> do - model <- liftIO readModel - mBody <- liftIO $ Catalog.readResource model kind - pure $ case mBody of - Nothing -> ProcessRPCError 404 $ "Resource not found: " <> uri - Just (ResourceBody mime body) -> - ProcessSuccess $ textResult uri mime body - } - -textResult :: Text -> Text -> Text -> ReadResourceResult -textResult uri mime body = - ReadResourceResult - { contents = - [ TextResource - TextResourceContents - { MCP.uri = uri - , text = body - , mimeType = Just mime - , MCP._meta = Nothing - } - ] - , MCP._meta = Nothing - } - -noteTemplate :: ResourceTemplate -noteTemplate = - ResourceTemplate - { MCP.name = "Notebook note" - , MCP.title = Just "Notebook note" - , uriTemplate = noteUriTemplate - , MCP.description = Just $ "Individual note by source path, e.g. " <> noteUriPrefix <> "guide/mcp.md" - , MCP.mimeType = Just "text/markdown" - , annotations = Nothing - , MCP._meta = Nothing - } +import Emanote.MCP.Server (run) diff --git a/emanote/src/Emanote/MCP/Handlers.hs b/emanote/src/Emanote/MCP/Handlers.hs new file mode 100644 index 000000000..148035976 --- /dev/null +++ b/emanote/src/Emanote/MCP/Handlers.hs @@ -0,0 +1,117 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# OPTIONS_GHC -Wno-orphans #-} + +{- | MCP request handlers. + +Bridges "Emanote.MCP.Catalog" (notebook data) to "MCP.Server" wire +types. Handlers pull the current model via the 'IO' 'Model' reader +supplied at startup and translate 'Catalog.NotebookResource' / +'Catalog.ResourceBody' into MCP's 'Resource' / 'ReadResourceResult'. +-} +module Emanote.MCP.Handlers ( + handlers, +) where + +import Emanote.MCP.Catalog (NotebookResource (..), ResourceBody (..)) +import Emanote.MCP.Catalog qualified as Catalog +import Emanote.MCP.Uri (kindToUri, noteUriPrefix, noteUriTemplate, uriToKind) +import Emanote.Model (Model) +import MCP.Server ( + ListResourceTemplatesResult (..), + ListResourcesResult (..), + MCPHandlerState, + MCPHandlerUser, + ProcessResult (..), + ReadResourceParams (..), + ReadResourceResult (..), + Resource (..), + ResourceContents (..), + ResourceTemplate (..), + TextResourceContents (..), + defaultProcessHandlers, + listResourceTemplatesHandler, + listResourcesHandler, + readResourceHandler, + withToolHandlers, + ) +import MCP.Server qualified as MCP +import Relude + +type instance MCPHandlerState = () + +-- | Unused: 'MCP.Server.simpleHttpApp' bypasses the JWT pipeline that would consume this. +type instance MCPHandlerUser = () + +handlers :: IO Model -> MCP.ProcessHandlers +handlers readModel = + withToolHandlers [] + $ defaultProcessHandlers + { listResourcesHandler = Just $ \_ -> do + model <- liftIO readModel + pure + $ ProcessSuccess + $ ListResourcesResult + { resources = toMcpResource <$> Catalog.listResources model + , nextCursor = Nothing + , MCP._meta = Nothing + } + , listResourceTemplatesHandler = Just $ \_ -> + pure + $ ProcessSuccess + $ ListResourceTemplatesResult + { resourceTemplates = [noteTemplate] + , nextCursor = Nothing + , MCP._meta = Nothing + } + , readResourceHandler = Just $ \ReadResourceParams {uri} -> + case uriToKind uri of + Nothing -> pure $ ProcessRPCError 404 $ "Resource not found: " <> uri + Just kind -> do + model <- liftIO readModel + mBody <- liftIO $ Catalog.readResource model kind + pure $ case mBody of + Nothing -> ProcessRPCError 404 $ "Resource not found: " <> uri + Just (ResourceBody mime body) -> + ProcessSuccess $ textResult uri mime body + } + +toMcpResource :: NotebookResource -> Resource +toMcpResource NotebookResource {resourceKind, resourceName, resourceTitle, resourceMime, resourceDescription} = + Resource + { MCP.uri = kindToUri resourceKind + , MCP.name = resourceName + , MCP.title = resourceTitle + , MCP.description = resourceDescription + , MCP.mimeType = Just resourceMime + , size = Nothing + , annotations = Nothing + , MCP._meta = Nothing + } + +textResult :: Text -> Text -> Text -> ReadResourceResult +textResult uri mime body = + ReadResourceResult + { contents = + [ TextResource + TextResourceContents + { MCP.uri = uri + , text = body + , mimeType = Just mime + , MCP._meta = Nothing + } + ] + , MCP._meta = Nothing + } + +noteTemplate :: ResourceTemplate +noteTemplate = + ResourceTemplate + { MCP.name = "Notebook note" + , MCP.title = Just "Notebook note" + , uriTemplate = noteUriTemplate + , MCP.description = Just $ "Individual note by source path, e.g. " <> noteUriPrefix <> "guide/mcp.md" + , MCP.mimeType = Just "text/markdown" + , annotations = Nothing + , MCP._meta = Nothing + } diff --git a/emanote/src/Emanote/MCP/Server.hs b/emanote/src/Emanote/MCP/Server.hs new file mode 100644 index 000000000..e6a0c8920 --- /dev/null +++ b/emanote/src/Emanote/MCP/Server.hs @@ -0,0 +1,88 @@ +{-# LANGUAGE DuplicateRecordFields #-} + +{- | MCP HTTP server setup. + +Wires Warp to "Emanote.MCP.Handlers" and declares MCP server identity, +instructions, and capabilities. The caller-supplied 'IO' 'Model' +reader is passed straight through to the handlers. +-} +module Emanote.MCP.Server ( + run, +) where + +import Data.Version (showVersion) +import Emanote.MCP.Handlers (handlers) +import Emanote.MCP.Uri (contentUri, metadataUri, noteUriPrefix, noteUriTemplate) +import Emanote.Model (Model) +import MCP.Server ( + Implementation (..), + LoggingLevel (..), + MCPServerState (..), + ResourcesCapability (..), + ServerCapabilities (..), + ToolsCapability (..), + initMCPServerState, + simpleHttpApp, + ) +import MCP.Server qualified as MCP +import Network.Wai.Handler.Warp qualified as Warp +import Paths_emanote qualified +import Relude +import System.IO (hPutStrLn) + +{- | 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. + +The @'IO' 'Model'@ reader must be non-blocking — handlers call it +synchronously from the request path. 'Ema.Dynamic.currentValue' +satisfies this (it reads an 'IORef' seeded with the Dynamic's initial +value before the reader is returned), and is the intended source. +-} +run :: Int -> Bool -> IO Model -> IO () +run port verbose readModel = do + stateVar <- + newMVar + (initMCPServerState () Nothing Nothing capabilities implementation instructions (handlers readModel)) + { 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 + $ unlines + [ "Emanote notebook exposed over MCP." + , "Resources:" + , "- " <> metadataUri <> " — JSON metadata for every note (titles, paths, parents, links)" + , "- " <> contentUri <> " — all notes concatenated as a single Markdown document" + , "- " <> noteUriTemplate <> " — individual note by source path (e.g. " <> noteUriPrefix <> "guide/mcp.md)" + ] + +capabilities :: ServerCapabilities +capabilities = + ServerCapabilities + { logging = Nothing + , prompts = Nothing + , resources = Just ResourcesCapability {listChanged = Nothing, subscribe = Nothing} + , tools = Just ToolsCapability {listChanged = Nothing} + , completions = Nothing + , experimental = Nothing + } diff --git a/emanote/src/Emanote/MCP/Uri.hs b/emanote/src/Emanote/MCP/Uri.hs new file mode 100644 index 000000000..b0c6b7176 --- /dev/null +++ b/emanote/src/Emanote/MCP/Uri.hs @@ -0,0 +1,44 @@ +{- | URI schema for the Emanote MCP resources. + +External clients hard-code these URIs — changing them is a breaking +protocol change. The schema is currently unversioned; if a future +phase needs versioning, a prefix revision lands here. +-} +module Emanote.MCP.Uri ( + metadataUri, + contentUri, + noteUriPrefix, + noteUriTemplate, + uriToKind, + kindToUri, +) where + +import Data.Text qualified as T +import Emanote.MCP.Catalog (ResourceKind (..)) +import Relude + +metadataUri :: Text +metadataUri = "emanote://export/metadata" + +contentUri :: Text +contentUri = "emanote://export/content" + +noteUriPrefix :: Text +noteUriPrefix = "emanote://note/" + +-- | RFC 6570 template for the per-note URI. +noteUriTemplate :: Text +noteUriTemplate = noteUriPrefix <> "{path}" + +uriToKind :: Text -> Maybe ResourceKind +uriToKind uri + | uri == metadataUri = Just MetadataJson + | uri == contentUri = Just ContentMarkdown + | Just path <- T.stripPrefix noteUriPrefix uri = Just (Note (toString path)) + | otherwise = Nothing + +kindToUri :: ResourceKind -> Text +kindToUri = \case + MetadataJson -> metadataUri + ContentMarkdown -> contentUri + Note path -> noteUriPrefix <> toText path From 4dac29a593704a59ed89fdf198d0b627988670fc Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Mon, 25 May 2026 12:38:37 -0400 Subject: [PATCH 14/26] refactor(hickey): drop ToolsCapability advertisement until tools land Phase 2 ships no MCP tools; advertising the capability with an empty list is a false interface contract. --- emanote/src/Emanote/MCP/Server.hs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/emanote/src/Emanote/MCP/Server.hs b/emanote/src/Emanote/MCP/Server.hs index e6a0c8920..58eec3c9e 100644 --- a/emanote/src/Emanote/MCP/Server.hs +++ b/emanote/src/Emanote/MCP/Server.hs @@ -20,7 +20,6 @@ import MCP.Server ( MCPServerState (..), ResourcesCapability (..), ServerCapabilities (..), - ToolsCapability (..), initMCPServerState, simpleHttpApp, ) @@ -82,7 +81,7 @@ capabilities = { logging = Nothing , prompts = Nothing , resources = Just ResourcesCapability {listChanged = Nothing, subscribe = Nothing} - , tools = Just ToolsCapability {listChanged = Nothing} + , tools = Nothing , completions = Nothing , experimental = Nothing } From f331ec383e29f44083a77863f945c2becf655c69 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Mon, 25 May 2026 12:39:17 -0400 Subject: [PATCH 15/26] refactor(hickey): document race_ ordering at MCP startup callsite 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. --- emanote/src/Emanote.hs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/emanote/src/Emanote.hs b/emanote/src/Emanote.hs index 5917d8335..440467b89 100644 --- a/emanote/src/Emanote.hs +++ b/emanote/src/Emanote.hs @@ -87,6 +87,11 @@ run cfg@EmanoteConfig {..} = do 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_ From beefc270d8a4ced23b4b1e08c8b0ef9020f929c8 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Mon, 25 May 2026 12:42:14 -0400 Subject: [PATCH 16/26] refactor(hickey): extract MCP type-family instances into Emanote.MCP.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. --- emanote/emanote.cabal | 1 + emanote/src/Emanote/MCP.hs | 1 + emanote/src/Emanote/MCP/Handlers.hs | 8 -------- emanote/src/Emanote/MCP/Server.hs | 1 + emanote/src/Emanote/MCP/Types.hs | 20 ++++++++++++++++++++ 5 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 emanote/src/Emanote/MCP/Types.hs diff --git a/emanote/emanote.cabal b/emanote/emanote.cabal index 75e30b8da..79d891aa3 100644 --- a/emanote/emanote.cabal +++ b/emanote/emanote.cabal @@ -176,6 +176,7 @@ library Emanote.MCP.Catalog Emanote.MCP.Handlers Emanote.MCP.Server + Emanote.MCP.Types Emanote.MCP.Uri Emanote.Model Emanote.Model.Calendar diff --git a/emanote/src/Emanote/MCP.hs b/emanote/src/Emanote/MCP.hs index 3c30ea393..c9913207e 100644 --- a/emanote/src/Emanote/MCP.hs +++ b/emanote/src/Emanote/MCP.hs @@ -8,6 +8,7 @@ Exposes the notebook model as read-only MCP resources: 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 diff --git a/emanote/src/Emanote/MCP/Handlers.hs b/emanote/src/Emanote/MCP/Handlers.hs index 148035976..8f06db7d0 100644 --- a/emanote/src/Emanote/MCP/Handlers.hs +++ b/emanote/src/Emanote/MCP/Handlers.hs @@ -1,6 +1,5 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} -{-# OPTIONS_GHC -Wno-orphans #-} {- | MCP request handlers. @@ -20,8 +19,6 @@ import Emanote.Model (Model) import MCP.Server ( ListResourceTemplatesResult (..), ListResourcesResult (..), - MCPHandlerState, - MCPHandlerUser, ProcessResult (..), ReadResourceParams (..), ReadResourceResult (..), @@ -38,11 +35,6 @@ import MCP.Server ( import MCP.Server qualified as MCP import Relude -type instance MCPHandlerState = () - --- | Unused: 'MCP.Server.simpleHttpApp' bypasses the JWT pipeline that would consume this. -type instance MCPHandlerUser = () - handlers :: IO Model -> MCP.ProcessHandlers handlers readModel = withToolHandlers [] diff --git a/emanote/src/Emanote/MCP/Server.hs b/emanote/src/Emanote/MCP/Server.hs index 58eec3c9e..94d125d96 100644 --- a/emanote/src/Emanote/MCP/Server.hs +++ b/emanote/src/Emanote/MCP/Server.hs @@ -12,6 +12,7 @@ module Emanote.MCP.Server ( import Data.Version (showVersion) import Emanote.MCP.Handlers (handlers) +import Emanote.MCP.Types () import Emanote.MCP.Uri (contentUri, metadataUri, noteUriPrefix, noteUriTemplate) import Emanote.Model (Model) import MCP.Server ( diff --git a/emanote/src/Emanote/MCP/Types.hs b/emanote/src/Emanote/MCP/Types.hs new file mode 100644 index 000000000..3d3956af8 --- /dev/null +++ b/emanote/src/Emanote/MCP/Types.hs @@ -0,0 +1,20 @@ +{-# OPTIONS_GHC -Wno-orphans #-} + +{- | Package-level type-family instances for the MCP server. + +The dpella/mcp library leaves 'MCPHandlerState' and 'MCPHandlerUser' +open so applications can plug in their own session-state and auth-user +types. Emanote uses neither — the HTTP transport bypasses the JWT +pipeline that would consume 'MCPHandlerUser' — so both collapse to '()'. + +These live in their own module so any transport (HTTP today; stdio or +otherwise tomorrow) can import them without depending on +"Emanote.MCP.Handlers". +-} +module Emanote.MCP.Types () where + +import MCP.Server (MCPHandlerState, MCPHandlerUser) + +type instance MCPHandlerState = () + +type instance MCPHandlerUser = () From b7580ac1f760064372719b5e65427bf895904280 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Mon, 25 May 2026 12:44:24 -0400 Subject: [PATCH 17/26] refactor(hickey): derive MCP resource MIME from ResourceKind 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. --- emanote/src/Emanote/MCP/Catalog.hs | 23 ++++++++++++----------- emanote/src/Emanote/MCP/Handlers.hs | 10 +++++----- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/emanote/src/Emanote/MCP/Catalog.hs b/emanote/src/Emanote/MCP/Catalog.hs index 1292845bc..1324b9ceb 100644 --- a/emanote/src/Emanote/MCP/Catalog.hs +++ b/emanote/src/Emanote/MCP/Catalog.hs @@ -16,6 +16,7 @@ module Emanote.MCP.Catalog ( ResourceKind (..), NotebookResource (..), ResourceBody (..), + kindMime, listResources, readResource, ) where @@ -42,20 +43,23 @@ data ResourceKind Note FilePath deriving stock (Show, Eq) +-- | MIME type of a resource, derived from its kind. +kindMime :: ResourceKind -> Text +kindMime = \case + MetadataJson -> "application/json" + ContentMarkdown -> "text/markdown" + Note _ -> "text/markdown" + -- | Catalog entry. URI-free by design; consumers assign addressing. data NotebookResource = NotebookResource { resourceKind :: ResourceKind , resourceName :: Text , resourceTitle :: Maybe Text - , resourceMime :: Text , resourceDescription :: Maybe Text } -- | Body payload for a resolved resource. -data ResourceBody = ResourceBody - { resourceBodyMime :: Text - , resourceBodyText :: Text - } +newtype ResourceBody = ResourceBody {resourceBodyText :: Text} -- | Enumerate all resources the notebook currently exposes. listResources :: Model -> [NotebookResource] @@ -67,14 +71,12 @@ staticResources = { resourceKind = MetadataJson , resourceName = "Notebook metadata" , resourceTitle = Just "Notebook metadata (JSON)" - , resourceMime = "application/json" , resourceDescription = Just "Notebook metadata as JSON: per-note titles, source paths, parent routes, and resolved links." } , NotebookResource { resourceKind = ContentMarkdown , resourceName = "Notebook content (single-file)" , resourceTitle = Just "Notebook content (single-file Markdown)" - , resourceMime = "text/markdown" , resourceDescription = Just "All notes concatenated into a single Markdown document, separated by '===' delimiters." } ] @@ -85,7 +87,6 @@ noteResources model = { resourceKind = Note sourcePath , resourceName = toText sourcePath , resourceTitle = Just (Tit.toPlain (Note._noteTitle note)) - , resourceMime = "text/markdown" , resourceDescription = Nothing } | note <- toList (model ^. M.modelNotes) @@ -101,10 +102,10 @@ correspond to any known note, or when the note has no source file readResource :: Model -> ResourceKind -> IO (Maybe ResourceBody) readResource model = \case MetadataJson -> - pure $ Just $ ResourceBody "application/json" (decodeUtf8 (ExportJSON.renderJSONExport model)) + pure $ Just $ ResourceBody (decodeUtf8 (ExportJSON.renderJSONExport model)) ContentMarkdown -> do body <- ExportContent.renderContentExport model - pure $ Just $ ResourceBody "text/markdown" body + pure $ Just $ ResourceBody body Note path -> case parseNoteRoute path >>= (`Note.lookupNotesByRoute` (model ^. M.modelNotes)) of Nothing -> pure Nothing @@ -113,7 +114,7 @@ readResource model = \case pure $ do content <- mContent let header = ExportContent.generateNoteHeader model note - Just $ ResourceBody "text/markdown" (header <> content) + Just $ ResourceBody (header <> content) parseNoteRoute :: FilePath -> Maybe R.LMLRoute parseNoteRoute fp = diff --git a/emanote/src/Emanote/MCP/Handlers.hs b/emanote/src/Emanote/MCP/Handlers.hs index 8f06db7d0..42e095414 100644 --- a/emanote/src/Emanote/MCP/Handlers.hs +++ b/emanote/src/Emanote/MCP/Handlers.hs @@ -12,7 +12,7 @@ module Emanote.MCP.Handlers ( handlers, ) where -import Emanote.MCP.Catalog (NotebookResource (..), ResourceBody (..)) +import Emanote.MCP.Catalog (NotebookResource (..), ResourceBody (..), kindMime) import Emanote.MCP.Catalog qualified as Catalog import Emanote.MCP.Uri (kindToUri, noteUriPrefix, noteUriTemplate, uriToKind) import Emanote.Model (Model) @@ -64,18 +64,18 @@ handlers readModel = mBody <- liftIO $ Catalog.readResource model kind pure $ case mBody of Nothing -> ProcessRPCError 404 $ "Resource not found: " <> uri - Just (ResourceBody mime body) -> - ProcessSuccess $ textResult uri mime body + Just (ResourceBody body) -> + ProcessSuccess $ textResult uri (kindMime kind) body } toMcpResource :: NotebookResource -> Resource -toMcpResource NotebookResource {resourceKind, resourceName, resourceTitle, resourceMime, resourceDescription} = +toMcpResource NotebookResource {resourceKind, resourceName, resourceTitle, resourceDescription} = Resource { MCP.uri = kindToUri resourceKind , MCP.name = resourceName , MCP.title = resourceTitle , MCP.description = resourceDescription - , MCP.mimeType = Just resourceMime + , MCP.mimeType = Just (kindMime resourceKind) , size = Nothing , annotations = Nothing , MCP._meta = Nothing From e06f5520d3f711257ba94ddbc3aed2df5493a3e7 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Mon, 25 May 2026 12:47:08 -0400 Subject: [PATCH 18/26] refactor(lowy): derive note ResourceTemplate from ResourceKind 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. --- emanote/src/Emanote/MCP/Handlers.hs | 42 ++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/emanote/src/Emanote/MCP/Handlers.hs b/emanote/src/Emanote/MCP/Handlers.hs index 42e095414..0e920f700 100644 --- a/emanote/src/Emanote/MCP/Handlers.hs +++ b/emanote/src/Emanote/MCP/Handlers.hs @@ -12,7 +12,7 @@ module Emanote.MCP.Handlers ( handlers, ) where -import Emanote.MCP.Catalog (NotebookResource (..), ResourceBody (..), kindMime) +import Emanote.MCP.Catalog (NotebookResource (..), ResourceBody (..), ResourceKind (..), kindMime) import Emanote.MCP.Catalog qualified as Catalog import Emanote.MCP.Uri (kindToUri, noteUriPrefix, noteUriTemplate, uriToKind) import Emanote.Model (Model) @@ -52,7 +52,7 @@ handlers readModel = pure $ ProcessSuccess $ ListResourceTemplatesResult - { resourceTemplates = [noteTemplate] + { resourceTemplates = mapMaybe templateFor allKindShapes , nextCursor = Nothing , MCP._meta = Nothing } @@ -96,14 +96,30 @@ textResult uri mime body = , MCP._meta = Nothing } -noteTemplate :: ResourceTemplate -noteTemplate = - ResourceTemplate - { MCP.name = "Notebook note" - , MCP.title = Just "Notebook note" - , uriTemplate = noteUriTemplate - , MCP.description = Just $ "Individual note by source path, e.g. " <> noteUriPrefix <> "guide/mcp.md" - , MCP.mimeType = Just "text/markdown" - , annotations = Nothing - , MCP._meta = Nothing - } +{- | One representative value per 'ResourceKind' constructor, used to drive +'templateFor' from 'listResourceTemplatesHandler'. The 'Note' path is +arbitrary — 'templateFor' only inspects the constructor. +-} +allKindShapes :: [ResourceKind] +allKindShapes = [MetadataJson, ContentMarkdown, Note ""] + +{- | The MCP resource template for a kind, if it accepts a URI parameter. + +Exhaustive on 'ResourceKind' so adding a new constructor forces a +decision about whether it deserves a template. +-} +templateFor :: ResourceKind -> Maybe ResourceTemplate +templateFor = \case + MetadataJson -> Nothing + ContentMarkdown -> Nothing + Note _ -> + Just + $ ResourceTemplate + { MCP.name = "Notebook note" + , MCP.title = Just "Notebook note" + , uriTemplate = noteUriTemplate + , MCP.description = Just $ "Individual note by source path, e.g. " <> noteUriPrefix <> "guide/mcp.md" + , MCP.mimeType = Just (kindMime (Note "")) + , annotations = Nothing + , MCP._meta = Nothing + } From e4e4d181984f4c581792b321a3ba6812fb41eaef Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Mon, 25 May 2026 12:49:20 -0400 Subject: [PATCH 19/26] refactor(lowy): distinguish unknown URI from missing resource 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. --- emanote/src/Emanote/MCP/Catalog.hs | 34 +++++++++++++++++++---------- emanote/src/Emanote/MCP/Handlers.hs | 12 +++++----- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/emanote/src/Emanote/MCP/Catalog.hs b/emanote/src/Emanote/MCP/Catalog.hs index 1324b9ceb..bb10615fe 100644 --- a/emanote/src/Emanote/MCP/Catalog.hs +++ b/emanote/src/Emanote/MCP/Catalog.hs @@ -16,6 +16,7 @@ module Emanote.MCP.Catalog ( ResourceKind (..), NotebookResource (..), ResourceBody (..), + CatalogError (..), kindMime, listResources, readResource, @@ -61,6 +62,16 @@ data NotebookResource = NotebookResource -- | Body payload for a resolved resource. newtype ResourceBody = ResourceBody {resourceBodyText :: Text} +{- | Why 'readResource' couldn't return a body. + +Distinguishes /the kind references nothing in the catalog/ from any +future IO-failure modes ('readNoteContent' surfaces a missing file as +'NotFound' today, since it can't tell that apart from a path with no +backing note in the model). +-} +data CatalogError = NotFound + deriving stock (Show, Eq) + -- | Enumerate all resources the notebook currently exposes. listResources :: Model -> [NotebookResource] listResources model = staticResources <> noteResources model @@ -95,26 +106,27 @@ noteResources model = {- | Resolve a 'ResourceKind' to its body. -Returns 'Nothing' when a 'Note' kind references a path that doesn't -correspond to any known note, or when the note has no source file -(auto-generated notes). +Returns 'Left' 'NotFound' when a 'Note' kind references a path that +doesn't correspond to any known note, or when the note has no source +file (auto-generated notes). -} -readResource :: Model -> ResourceKind -> IO (Maybe ResourceBody) +readResource :: Model -> ResourceKind -> IO (Either CatalogError ResourceBody) readResource model = \case MetadataJson -> - pure $ Just $ ResourceBody (decodeUtf8 (ExportJSON.renderJSONExport model)) + pure $ Right $ ResourceBody (decodeUtf8 (ExportJSON.renderJSONExport model)) ContentMarkdown -> do body <- ExportContent.renderContentExport model - pure $ Just $ ResourceBody body + pure $ Right $ ResourceBody body Note path -> case parseNoteRoute path >>= (`Note.lookupNotesByRoute` (model ^. M.modelNotes)) of - Nothing -> pure Nothing + Nothing -> pure $ Left NotFound Just note -> do mContent <- ExportContent.readNoteContent note - pure $ do - content <- mContent - let header = ExportContent.generateNoteHeader model note - Just $ ResourceBody (header <> content) + pure $ case mContent of + Nothing -> Left NotFound + Just content -> + let header = ExportContent.generateNoteHeader model note + in Right $ ResourceBody (header <> content) parseNoteRoute :: FilePath -> Maybe R.LMLRoute parseNoteRoute fp = diff --git a/emanote/src/Emanote/MCP/Handlers.hs b/emanote/src/Emanote/MCP/Handlers.hs index 0e920f700..d86a185ae 100644 --- a/emanote/src/Emanote/MCP/Handlers.hs +++ b/emanote/src/Emanote/MCP/Handlers.hs @@ -12,7 +12,7 @@ module Emanote.MCP.Handlers ( handlers, ) where -import Emanote.MCP.Catalog (NotebookResource (..), ResourceBody (..), ResourceKind (..), kindMime) +import Emanote.MCP.Catalog (CatalogError (..), NotebookResource (..), ResourceBody (..), ResourceKind (..), kindMime) import Emanote.MCP.Catalog qualified as Catalog import Emanote.MCP.Uri (kindToUri, noteUriPrefix, noteUriTemplate, uriToKind) import Emanote.Model (Model) @@ -58,13 +58,13 @@ handlers readModel = } , readResourceHandler = Just $ \ReadResourceParams {uri} -> case uriToKind uri of - Nothing -> pure $ ProcessRPCError 404 $ "Resource not found: " <> uri + Nothing -> pure $ ProcessRPCError 400 $ "Unrecognized resource URI: " <> uri Just kind -> do model <- liftIO readModel - mBody <- liftIO $ Catalog.readResource model kind - pure $ case mBody of - Nothing -> ProcessRPCError 404 $ "Resource not found: " <> uri - Just (ResourceBody body) -> + eBody <- liftIO $ Catalog.readResource model kind + pure $ case eBody of + Left NotFound -> ProcessRPCError 404 $ "Resource not found: " <> uri + Right (ResourceBody body) -> ProcessSuccess $ textResult uri (kindMime kind) body } From f3a01998f7aecaaf67b8e19b62576094f970bcb0 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Mon, 25 May 2026 12:52:10 -0400 Subject: [PATCH 20/26] refactor(lowy): generate MCP server instructions from catalog descriptions 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. --- emanote/src/Emanote/MCP/Catalog.hs | 1 + emanote/src/Emanote/MCP/Handlers.hs | 2 ++ emanote/src/Emanote/MCP/Server.hs | 23 +++++++++++++++-------- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/emanote/src/Emanote/MCP/Catalog.hs b/emanote/src/Emanote/MCP/Catalog.hs index bb10615fe..fa582aded 100644 --- a/emanote/src/Emanote/MCP/Catalog.hs +++ b/emanote/src/Emanote/MCP/Catalog.hs @@ -19,6 +19,7 @@ module Emanote.MCP.Catalog ( CatalogError (..), kindMime, listResources, + staticResources, readResource, ) where diff --git a/emanote/src/Emanote/MCP/Handlers.hs b/emanote/src/Emanote/MCP/Handlers.hs index d86a185ae..5de1807f9 100644 --- a/emanote/src/Emanote/MCP/Handlers.hs +++ b/emanote/src/Emanote/MCP/Handlers.hs @@ -10,6 +10,8 @@ supplied at startup and translate 'Catalog.NotebookResource' / -} module Emanote.MCP.Handlers ( handlers, + allKindShapes, + templateFor, ) where import Emanote.MCP.Catalog (CatalogError (..), NotebookResource (..), ResourceBody (..), ResourceKind (..), kindMime) diff --git a/emanote/src/Emanote/MCP/Server.hs b/emanote/src/Emanote/MCP/Server.hs index 94d125d96..3eddb16ca 100644 --- a/emanote/src/Emanote/MCP/Server.hs +++ b/emanote/src/Emanote/MCP/Server.hs @@ -1,4 +1,5 @@ {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} {- | MCP HTTP server setup. @@ -11,14 +12,17 @@ module Emanote.MCP.Server ( ) where import Data.Version (showVersion) -import Emanote.MCP.Handlers (handlers) +import Emanote.MCP.Catalog (NotebookResource (..)) +import Emanote.MCP.Catalog qualified as Catalog +import Emanote.MCP.Handlers (allKindShapes, handlers, templateFor) import Emanote.MCP.Types () -import Emanote.MCP.Uri (contentUri, metadataUri, noteUriPrefix, noteUriTemplate) +import Emanote.MCP.Uri (kindToUri) import Emanote.Model (Model) import MCP.Server ( Implementation (..), LoggingLevel (..), MCPServerState (..), + ResourceTemplate (..), ResourcesCapability (..), ServerCapabilities (..), initMCPServerState, @@ -69,12 +73,15 @@ instructions :: Maybe Text instructions = Just $ unlines - [ "Emanote notebook exposed over MCP." - , "Resources:" - , "- " <> metadataUri <> " — JSON metadata for every note (titles, paths, parents, links)" - , "- " <> contentUri <> " — all notes concatenated as a single Markdown document" - , "- " <> noteUriTemplate <> " — individual note by source path (e.g. " <> noteUriPrefix <> "guide/mcp.md)" - ] + $ "Emanote notebook exposed over MCP." + : "Resources:" + : (resourceLine <$> Catalog.staticResources) + <> (templateLine <$> mapMaybe templateFor allKindShapes) + where + resourceLine NotebookResource {resourceKind, resourceDescription} = + "- " <> kindToUri resourceKind <> maybe "" (" — " <>) resourceDescription + templateLine ResourceTemplate {uriTemplate, description} = + "- " <> uriTemplate <> maybe "" (" — " <>) description capabilities :: ServerCapabilities capabilities = From 1d0e6e6007d67962550fb8e5f762ba654a2b0614 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Mon, 25 May 2026 13:08:52 -0400 Subject: [PATCH 21/26] =?UTF-8?q?refactor(police):=20elegance=20=E2=80=94?= =?UTF-8?q?=20use=20record=20wildcard=20in=20kindMime=20Note=20arm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Note {} signals 'ignore all fields' more explicitly than Note _ for a single-arity constructor, and survives any future field additions. --- emanote/src/Emanote/MCP/Catalog.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emanote/src/Emanote/MCP/Catalog.hs b/emanote/src/Emanote/MCP/Catalog.hs index fa582aded..61f626914 100644 --- a/emanote/src/Emanote/MCP/Catalog.hs +++ b/emanote/src/Emanote/MCP/Catalog.hs @@ -50,7 +50,7 @@ kindMime :: ResourceKind -> Text kindMime = \case MetadataJson -> "application/json" ContentMarkdown -> "text/markdown" - Note _ -> "text/markdown" + Note {} -> "text/markdown" -- | Catalog entry. URI-free by design; consumers assign addressing. data NotebookResource = NotebookResource From 2f50b1ad49dec5751fe6689bbea7df57ea1140ef Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Mon, 25 May 2026 13:09:08 -0400 Subject: [PATCH 22/26] =?UTF-8?q?refactor(police):=20elegance=20=E2=80=94?= =?UTF-8?q?=20point-free=20readResource=20ContentMarkdown=20arm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse 'do { x <- m; pure (f x) }' into 'f <$> m'. --- emanote/src/Emanote/MCP/Catalog.hs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/emanote/src/Emanote/MCP/Catalog.hs b/emanote/src/Emanote/MCP/Catalog.hs index 61f626914..abadae400 100644 --- a/emanote/src/Emanote/MCP/Catalog.hs +++ b/emanote/src/Emanote/MCP/Catalog.hs @@ -115,9 +115,8 @@ readResource :: Model -> ResourceKind -> IO (Either CatalogError ResourceBody) readResource model = \case MetadataJson -> pure $ Right $ ResourceBody (decodeUtf8 (ExportJSON.renderJSONExport model)) - ContentMarkdown -> do - body <- ExportContent.renderContentExport model - pure $ Right $ ResourceBody body + ContentMarkdown -> + Right . ResourceBody <$> ExportContent.renderContentExport model Note path -> case parseNoteRoute path >>= (`Note.lookupNotesByRoute` (model ^. M.modelNotes)) of Nothing -> pure $ Left NotFound From 46cdb2c3101d8b5406000c5051869a9c8e798cab Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Mon, 25 May 2026 13:09:40 -0400 Subject: [PATCH 23/26] =?UTF-8?q?refactor(police):=20elegance=20=E2=80=94?= =?UTF-8?q?=20uniform=20list=20construction=20in=20instructions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cons-then-append (':' + '<>') reads more awkwardly than a plain list literal followed by '++' for each section. Same semantics, single left-to-right concat. --- emanote/src/Emanote/MCP/Server.hs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/emanote/src/Emanote/MCP/Server.hs b/emanote/src/Emanote/MCP/Server.hs index 3eddb16ca..72cc04dbd 100644 --- a/emanote/src/Emanote/MCP/Server.hs +++ b/emanote/src/Emanote/MCP/Server.hs @@ -73,10 +73,9 @@ instructions :: Maybe Text instructions = Just $ unlines - $ "Emanote notebook exposed over MCP." - : "Resources:" - : (resourceLine <$> Catalog.staticResources) - <> (templateLine <$> mapMaybe templateFor allKindShapes) + $ ["Emanote notebook exposed over MCP.", "Resources:"] + ++ (resourceLine <$> Catalog.staticResources) + ++ (templateLine <$> mapMaybe templateFor allKindShapes) where resourceLine NotebookResource {resourceKind, resourceDescription} = "- " <> kindToUri resourceKind <> maybe "" (" — " <>) resourceDescription From 891a352802a2bd9705127c2662c9faf7895a0246 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Mon, 25 May 2026 13:50:29 -0400 Subject: [PATCH 24/26] feat(mcp): drop per-note enumeration from resources/list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/guide/mcp.md | 8 ++++++- emanote/CHANGELOG.md | 2 +- emanote/src/Emanote/MCP/Catalog.hs | 33 +++++++++++++++-------------- emanote/src/Emanote/MCP/Handlers.hs | 5 ++--- 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 2f407aadf..1dfd4d0c8 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -49,7 +49,13 @@ Emanote advertises the notebook as three URI schemes under the `emanote://` sche | `emanote://export/content` | `text/markdown` | All notes concatenated into a single Markdown document with delimiters and an LLM-oriented preamble. Same shape as [`emanote export --format=content`](export.md). | | `emanote://note/{path}` | `text/markdown` | One note, by its source path (e.g. `emanote://note/guide/mcp.md`). Prefixed with a header block (``, ``, ``, ``). | -`resources/list` returns the two static exports plus one entry per note; `resources/templates/list` advertises the `emanote://note/{path}` template for clients that support [RFC 6570 URI templates](https://datatracker.ietf.org/doc/html/rfc6570). +`resources/list` returns only the two static exports — Emanote intentionally does **not** enumerate one entry per note, since 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`). + +### 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. ### Codex diff --git a/emanote/CHANGELOG.md b/emanote/CHANGELOG.md index 00cfae8eb..b7015858a 100644 --- a/emanote/CHANGELOG.md +++ b/emanote/CHANGELOG.md @@ -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**: 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), `emanote://export/content` (single-file Markdown dump), and `emanote://note/{path}` for individual notes. Query tools and subscriptions follow in later phases ([#645](https://github.com/srid/emanote/issues/645), [#649](https://github.com/srid/emanote/pull/649)) +- **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), `emanote://export/content` (single-file Markdown dump), and per-note reads via the `emanote://note/{path}` URI template. `resources/list` returns only the two static exports to keep the response size independent of notebook size; clients address individual notes through the template. 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 `
`/`` ([#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)) diff --git a/emanote/src/Emanote/MCP/Catalog.hs b/emanote/src/Emanote/MCP/Catalog.hs index abadae400..317b52198 100644 --- a/emanote/src/Emanote/MCP/Catalog.hs +++ b/emanote/src/Emanote/MCP/Catalog.hs @@ -26,7 +26,6 @@ module Emanote.MCP.Catalog ( import Emanote.Model (Model) import Emanote.Model qualified as M import Emanote.Model.Note qualified as Note -import Emanote.Model.Title qualified as Tit import Emanote.Route qualified as R import Emanote.Route.Ext (LML (Md, Org)) import Emanote.Route.ModelRoute (mkLMLRouteFromKnownFilePath) @@ -73,9 +72,23 @@ backing note in the model). data CatalogError = NotFound deriving stock (Show, Eq) --- | Enumerate all resources the notebook currently exposes. -listResources :: Model -> [NotebookResource] -listResources model = staticResources <> noteResources model +{- | Enumerate the resources advertised through MCP's @resources\/list@. + +Returns only the two static, whole-notebook exports. Per-note resources +are intentionally not enumerated: enumerating one entry per note makes +@resources\/list@ scale linearly with notebook size, which clients poll +on every refresh and which inflates context for clients that load the +list eagerly. Per-note addressing is still fully supported through the +@emanote:\/\/note\/{path}@ URI template advertised via +@resources\/templates\/list@: discover paths from +@emanote:\/\/export\/metadata@ (or wikilink graph) and call +@resources\/read@ directly. Clients that surface only enumerated +resources in an @-mention picker (Claude Code, opencode) won't fuzzy-list +individual notes; clients that drive resource reads from the model +(Codex, and Claude Code's model-side read tool) are unaffected. +-} +listResources :: [NotebookResource] +listResources = staticResources staticResources :: [NotebookResource] staticResources = @@ -93,18 +106,6 @@ staticResources = } ] -noteResources :: Model -> [NotebookResource] -noteResources model = - [ NotebookResource - { resourceKind = Note sourcePath - , resourceName = toText sourcePath - , resourceTitle = Just (Tit.toPlain (Note._noteTitle note)) - , resourceDescription = Nothing - } - | note <- toList (model ^. M.modelNotes) - , let sourcePath = ExportJSON.lmlSourcePath (Note._noteRoute note) - ] - {- | Resolve a 'ResourceKind' to its body. Returns 'Left' 'NotFound' when a 'Note' kind references a path that diff --git a/emanote/src/Emanote/MCP/Handlers.hs b/emanote/src/Emanote/MCP/Handlers.hs index 5de1807f9..42a2a4f49 100644 --- a/emanote/src/Emanote/MCP/Handlers.hs +++ b/emanote/src/Emanote/MCP/Handlers.hs @@ -41,12 +41,11 @@ handlers :: IO Model -> MCP.ProcessHandlers handlers readModel = withToolHandlers [] $ defaultProcessHandlers - { listResourcesHandler = Just $ \_ -> do - model <- liftIO readModel + { listResourcesHandler = Just $ \_ -> pure $ ProcessSuccess $ ListResourcesResult - { resources = toMcpResource <$> Catalog.listResources model + { resources = toMcpResource <$> Catalog.listResources , nextCursor = Nothing , MCP._meta = Nothing } From 221669a08618b3804daea78ba47a3b6a43c6733b Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Mon, 25 May 2026 14:09:37 -0400 Subject: [PATCH 25/26] docs(mcp): annotate per-route algorithmic complexity + restructure 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. --- docs/guide/mcp.md | 53 ++++++++++++++++++----------- emanote/src/Emanote/MCP/Catalog.hs | 17 ++++++++- emanote/src/Emanote/MCP/Handlers.hs | 15 ++++++++ 3 files changed, 65 insertions(+), 20 deletions(-) diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 1dfd4d0c8..0a6c40a38 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -37,25 +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. - -## Resources - -Emanote advertises the notebook as three URI schemes under the `emanote://` scheme: - -| 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). | -| `emanote://export/content` | `text/markdown` | All notes concatenated into a single Markdown document with delimiters and an LLM-oriented preamble. Same shape as [`emanote export --format=content`](export.md). | -| `emanote://note/{path}` | `text/markdown` | One note, by its source path (e.g. `emanote://note/guide/mcp.md`). Prefixed with a header block (``, ``, ``, ``). | - -`resources/list` returns only the two static exports — Emanote intentionally does **not** enumerate one entry per note, since 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`). - -### 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. +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 @@ -81,6 +63,39 @@ 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 as three URIs under the `emanote://` scheme: + +| 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). | +| `emanote://export/content` | `text/markdown` | All notes concatenated into a single Markdown document with delimiters and an LLM-oriented preamble. Same shape as [`emanote export --format=content`](export.md). | +| `emanote://note/{path}` | `text/markdown` | One note, by its source path (e.g. `emanote://note/guide/mcp.md`). Prefixed with a header block (``, ``, ``, ``). | + +`resources/list` returns only the two static exports — Emanote intentionally does **not** enumerate one entry per note, since 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`). + +### 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)** — fixed two static entries, 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://export/content` | **O(N log N + Σ \|note\|)** — sorts notes by path then reads each from disk; IO-dominated for large notebooks | +| `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 polls `emanote://export/content` in a loop on a 422-note notebook will re-traverse the disk each time. Phase 4 (subscriptions) replaces polling with push notifications and removes the constant factor. + +### 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. diff --git a/emanote/src/Emanote/MCP/Catalog.hs b/emanote/src/Emanote/MCP/Catalog.hs index 317b52198..8e460eb3e 100644 --- a/emanote/src/Emanote/MCP/Catalog.hs +++ b/emanote/src/Emanote/MCP/Catalog.hs @@ -44,7 +44,10 @@ data ResourceKind Note FilePath deriving stock (Show, Eq) --- | MIME type of a resource, derived from its kind. +{- | MIME type of a resource, derived from its kind. + +__Complexity:__ /O(1)/. +-} kindMime :: ResourceKind -> Text kindMime = \case MetadataJson -> "application/json" @@ -74,6 +77,9 @@ data CatalogError = NotFound {- | Enumerate the resources advertised through MCP's @resources\/list@. +__Complexity:__ /O(1)/ — fixed two static entries, independent of +notebook size. + Returns only the two static, whole-notebook exports. Per-note resources are intentionally not enumerated: enumerating one entry per note makes @resources\/list@ scale linearly with notebook size, which clients poll @@ -108,6 +114,15 @@ staticResources = {- | Resolve a 'ResourceKind' to its body. +__Complexity__ (per-kind, where /N/ = number of notes and /R/ = total +resolved relations across all notes): + +* @'MetadataJson'@ — /O(N + R)/. Iterates every note in + 'Emanote.View.Export.JSON.renderJSONExport' and encodes the result. +* @'ContentMarkdown'@ — /O(N log N + Σ |note|)/. Sorts notes by source + path, then reads each note's source file from disk. IO-dominated. +* @'Note' path@ — /O(log N + |note|)/. ixset lookup plus one file read. + Returns 'Left' 'NotFound' when a 'Note' kind references a path that doesn't correspond to any known note, or when the note has no source file (auto-generated notes). diff --git a/emanote/src/Emanote/MCP/Handlers.hs b/emanote/src/Emanote/MCP/Handlers.hs index 42a2a4f49..7784c75ef 100644 --- a/emanote/src/Emanote/MCP/Handlers.hs +++ b/emanote/src/Emanote/MCP/Handlers.hs @@ -7,6 +7,19 @@ Bridges "Emanote.MCP.Catalog" (notebook data) to "MCP.Server" wire types. Handlers pull the current model via the 'IO' 'Model' reader supplied at startup and translate 'Catalog.NotebookResource' / 'Catalog.ResourceBody' into MCP's 'Resource' / 'ReadResourceResult'. + +__Per-request complexity__ (with /N/ = number of notes, /R/ = total +relations): + +* @resources\/list@ — /O(1)/. Returns 'Catalog.listResources' verbatim. +* @resources\/templates\/list@ — /O(1)/. 'mapMaybe' over the fixed + 'allKindShapes' list. +* @resources\/read@ — /O(|URI|)/ for the URI parse plus the per-kind + cost from 'Catalog.readResource' (/O(N + R)/ for metadata, + /O(N log N + Σ |note|)/ for content, /O(log N + |note|)/ for a single + note). + +No caching: each call re-runs against the live model. -} module Emanote.MCP.Handlers ( handlers, @@ -106,6 +119,8 @@ allKindShapes = [MetadataJson, ContentMarkdown, Note ""] {- | The MCP resource template for a kind, if it accepts a URI parameter. +__Complexity:__ /O(1)/. Independent of notebook size. + Exhaustive on 'ResourceKind' so adding a new constructor forces a decision about whether it deserves a template. -} From 8c677924f26088d229bd8f0ee2459618549d493f Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Mon, 25 May 2026 16:14:03 -0400 Subject: [PATCH 26/26] feat(mcp)!: drop emanote://export/content resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/guide/mcp.md | 15 ++++++++------- emanote/CHANGELOG.md | 2 +- emanote/src/Emanote/MCP/Catalog.hs | 19 +++---------------- emanote/src/Emanote/MCP/Handlers.hs | 6 ++---- emanote/src/Emanote/MCP/Uri.hs | 6 ------ 5 files changed, 14 insertions(+), 34 deletions(-) diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 0a6c40a38..a44552dd3 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -65,15 +65,17 @@ You should see an SSE `event: message` frame carrying the server's implementatio ## Resources -Emanote advertises the notebook as three URIs under the `emanote://` scheme: +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). | -| `emanote://export/content` | `text/markdown` | All notes concatenated into a single Markdown document with delimiters and an LLM-oriented preamble. Same shape as [`emanote export --format=content`](export.md). | +| `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 (``, ``, ``, ``). | -`resources/list` returns only the two static exports — Emanote intentionally does **not** enumerate one entry per note, since 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`). +`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 @@ -82,13 +84,12 @@ Per-request cost, where _N_ = number of notes in the model and _R_ = total resol | MCP method | Cost in notebook size | |---|---| | `initialize` | **O(1)** | -| `resources/list` | **O(1)** — fixed two static entries, independent of _N_ | +| `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://export/content` | **O(N log N + Σ \|note\|)** — sorts notes by path then reads each from disk; IO-dominated for large notebooks | | `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 polls `emanote://export/content` in a loop on a 422-note notebook will re-traverse the disk each time. Phase 4 (subscriptions) replaces polling with push notifications and removes the constant factor. +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 diff --git a/emanote/CHANGELOG.md b/emanote/CHANGELOG.md index b7015858a..8e5a5b6f3 100644 --- a/emanote/CHANGELOG.md +++ b/emanote/CHANGELOG.md @@ -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**: 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), `emanote://export/content` (single-file Markdown dump), and per-note reads via the `emanote://note/{path}` URI template. `resources/list` returns only the two static exports to keep the response size independent of notebook size; clients address individual notes through the template. Query tools and subscriptions follow in later phases ([#645](https://github.com/srid/emanote/issues/645), [#649](https://github.com/srid/emanote/pull/649)) +- **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 `
`/`` ([#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)) diff --git a/emanote/src/Emanote/MCP/Catalog.hs b/emanote/src/Emanote/MCP/Catalog.hs index 8e460eb3e..25079d00b 100644 --- a/emanote/src/Emanote/MCP/Catalog.hs +++ b/emanote/src/Emanote/MCP/Catalog.hs @@ -2,8 +2,8 @@ Answers two questions: -* /What/ is available? — 'listResources' returns catalog entries, one per - static export ('MetadataJson', 'ContentMarkdown') and one per note. +* /What/ is available? — 'listResources' returns catalog entries for + the static metadata export. * /How do I fetch one?/ — 'readResource' resolves a 'ResourceKind' to a 'ResourceBody'. @@ -38,8 +38,6 @@ import Relude data ResourceKind = -- | Whole-notebook metadata as JSON. MetadataJson - | -- | Whole-notebook concatenated Markdown. - ContentMarkdown | -- | Individual note by source-relative path (e.g. @guide/mcp.md@). Note FilePath deriving stock (Show, Eq) @@ -51,7 +49,6 @@ __Complexity:__ /O(1)/. kindMime :: ResourceKind -> Text kindMime = \case MetadataJson -> "application/json" - ContentMarkdown -> "text/markdown" Note {} -> "text/markdown" -- | Catalog entry. URI-free by design; consumers assign addressing. @@ -102,13 +99,7 @@ staticResources = { resourceKind = MetadataJson , resourceName = "Notebook metadata" , resourceTitle = Just "Notebook metadata (JSON)" - , resourceDescription = Just "Notebook metadata as JSON: per-note titles, source paths, parent routes, and resolved links." - } - , NotebookResource - { resourceKind = ContentMarkdown - , resourceName = "Notebook content (single-file)" - , resourceTitle = Just "Notebook content (single-file Markdown)" - , resourceDescription = Just "All notes concatenated into a single Markdown document, separated by '===' delimiters." + , resourceDescription = Just "Notebook metadata as JSON: per-note titles, source paths, parent routes, and resolved links. Use this to discover note paths, then read individual notes via the emanote://note/{path} template." } ] @@ -119,8 +110,6 @@ resolved relations across all notes): * @'MetadataJson'@ — /O(N + R)/. Iterates every note in 'Emanote.View.Export.JSON.renderJSONExport' and encodes the result. -* @'ContentMarkdown'@ — /O(N log N + Σ |note|)/. Sorts notes by source - path, then reads each note's source file from disk. IO-dominated. * @'Note' path@ — /O(log N + |note|)/. ixset lookup plus one file read. Returns 'Left' 'NotFound' when a 'Note' kind references a path that @@ -131,8 +120,6 @@ readResource :: Model -> ResourceKind -> IO (Either CatalogError ResourceBody) readResource model = \case MetadataJson -> pure $ Right $ ResourceBody (decodeUtf8 (ExportJSON.renderJSONExport model)) - ContentMarkdown -> - Right . ResourceBody <$> ExportContent.renderContentExport model Note path -> case parseNoteRoute path >>= (`Note.lookupNotesByRoute` (model ^. M.modelNotes)) of Nothing -> pure $ Left NotFound diff --git a/emanote/src/Emanote/MCP/Handlers.hs b/emanote/src/Emanote/MCP/Handlers.hs index 7784c75ef..9888b544b 100644 --- a/emanote/src/Emanote/MCP/Handlers.hs +++ b/emanote/src/Emanote/MCP/Handlers.hs @@ -16,8 +16,7 @@ relations): 'allKindShapes' list. * @resources\/read@ — /O(|URI|)/ for the URI parse plus the per-kind cost from 'Catalog.readResource' (/O(N + R)/ for metadata, - /O(N log N + Σ |note|)/ for content, /O(log N + |note|)/ for a single - note). + /O(log N + |note|)/ for a single note). No caching: each call re-runs against the live model. -} @@ -115,7 +114,7 @@ textResult uri mime body = arbitrary — 'templateFor' only inspects the constructor. -} allKindShapes :: [ResourceKind] -allKindShapes = [MetadataJson, ContentMarkdown, Note ""] +allKindShapes = [MetadataJson, Note ""] {- | The MCP resource template for a kind, if it accepts a URI parameter. @@ -127,7 +126,6 @@ decision about whether it deserves a template. templateFor :: ResourceKind -> Maybe ResourceTemplate templateFor = \case MetadataJson -> Nothing - ContentMarkdown -> Nothing Note _ -> Just $ ResourceTemplate diff --git a/emanote/src/Emanote/MCP/Uri.hs b/emanote/src/Emanote/MCP/Uri.hs index b0c6b7176..aee16d3f9 100644 --- a/emanote/src/Emanote/MCP/Uri.hs +++ b/emanote/src/Emanote/MCP/Uri.hs @@ -6,7 +6,6 @@ phase needs versioning, a prefix revision lands here. -} module Emanote.MCP.Uri ( metadataUri, - contentUri, noteUriPrefix, noteUriTemplate, uriToKind, @@ -20,9 +19,6 @@ import Relude metadataUri :: Text metadataUri = "emanote://export/metadata" -contentUri :: Text -contentUri = "emanote://export/content" - noteUriPrefix :: Text noteUriPrefix = "emanote://note/" @@ -33,12 +29,10 @@ noteUriTemplate = noteUriPrefix <> "{path}" uriToKind :: Text -> Maybe ResourceKind uriToKind uri | uri == metadataUri = Just MetadataJson - | uri == contentUri = Just ContentMarkdown | Just path <- T.stripPrefix noteUriPrefix uri = Just (Note (toString path)) | otherwise = Nothing kindToUri :: ResourceKind -> Text kindToUri = \case MetadataJson -> metadataUri - ContentMarkdown -> contentUri Note path -> noteUriPrefix <> toText path