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" } } } diff --git a/apm.yml b/apm.yml index 2d08f53dd..39fb9166e 100644 --- a/apm.yml +++ b/apm.yml @@ -16,5 +16,9 @@ dependencies: - juspay/skills/skills/nix-playwright - juspay/nix-chrome-devtools-mcp - anthropics/skills/skills/frontend-design - mcp: [] + mcp: + - name: emanote + registry: false + transport: http + url: http://localhost:8079/mcp scripts: {} diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 147317df7..a44552dd3 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](https://ema.srid.ca/topics/live-server), so that [Claude Code](https://claude.com/claude-code), [Codex](https://github.com/openai/codex), or any other MCP-aware client can query your notebook directly from the same process that renders it. @@ -37,7 +37,7 @@ Claude Code reads MCP server configuration from `.mcp.json` in your project root } ``` -Start Emanote in one terminal (`emanote run --mcp-port 8079`), launch Claude Code in the same directory, and it will connect on startup. Use `/mcp` inside Claude Code to verify the server appears and list its tools/resources. +Start Emanote in one terminal (`emanote run --mcp-port 8079`), launch Claude Code in the same directory, and it will connect on startup. Use `/mcp` inside Claude Code to verify the server appears and list its resources. ### Codex @@ -63,6 +63,40 @@ curl -sS -N -X POST http://localhost:8079/mcp \ You should see an SSE `event: message` frame carrying the server's implementation metadata and advertised capabilities. +## Resources + +Emanote advertises the notebook under the `emanote://` scheme as one static export plus a per-note URI template: + +| URI | MIME | What it returns | +|---|---|---| +| `emanote://export/metadata` | `application/json` | Metadata for every note — titles, source paths, parent routes, resolved links. Same shape as [`emanote export --format=metadata`](export.md). Use this to discover paths. | +| `emanote://note/{path}` | `text/markdown` | One note, by its source path (e.g. `emanote://note/guide/mcp.md`). Prefixed with a header block (``, ``, ``, ``). | + +`resources/list` returns only the metadata export. Emanote intentionally does **not** enumerate one entry per note: that scales linearly with notebook size and inflates context on every poll. `resources/templates/list` advertises the `emanote://note/{path}` template for clients that support [RFC 6570 URI templates](https://datatracker.ietf.org/doc/html/rfc6570); to address a specific note, construct a URI from the template and call `resources/read` directly. Discover the set of valid paths from `emanote://export/metadata` (every note's `srcPath`). + +> [!note] No bundled-content export +> Earlier drafts of phase 2 exposed `emanote://export/content` (every note concatenated into a single Markdown blob). It was removed before merge: the same information is available via metadata + per-note reads, the blob blows context budgets on any non-trivial notebook (a 422-note notebook is well past any reasonable LLM window), and an MCP client that polls it re-reads the whole disk every time. The `emanote export --format=content` CLI still produces this artifact for human/script use; MCP is the wrong transport for batch export. + +### Algorithmic complexity + +Per-request cost, where _N_ = number of notes in the model and _R_ = total resolved relations (wikilinks + transclusions) across all notes: + +| MCP method | Cost in notebook size | +|---|---| +| `initialize` | **O(1)** | +| `resources/list` | **O(1)** — one fixed static entry, independent of _N_ | +| `resources/templates/list` | **O(1)** — currently one template (per-note); grows with templated kinds, not with notebook size | +| `resources/read emanote://export/metadata` | **O(N + R)** — iterates every note and every relation; JSON-encodes the result | +| `resources/read emanote://note/{path}` | **O(log N + \|note\|)** — ixset lookup plus one file read | + +Reads are uncached: every `resources/read` re-runs against the live model. There is no per-client throttling or coalescing — a client that loops over per-note reads will re-traverse the disk each time. Phase 4 (subscriptions) replaces polling with push notifications and removes the per-poll cost for clients that opt in. + +### Per-client behaviour + +- **Codex** sees the template in the model-side `list_mcp_resource_templates` tool and can call `read_mcp_resource` against any path. Works out of the box. +- **Claude Code**'s model-side read tool ([docs](https://code.claude.com/docs/en/mcp.md#use-mcp-resources)) reads any URI the model constructs, including ones derived from the template. The `@`-mention picker, however, fuzzy-searches only the enumerated `resources/list` entries — so users won't see individual notes there and must reference them by asking the model (e.g. "read `guide/mcp.md` from the notebook") instead of `@`-mentioning them. Phase 3 will add a `find_notes` tool to make this lookup explicit. +- **opencode** populates its attach picker from `resources/list` only; per-note attachment via UI is unavailable without an enumeration. Same model-driven workaround as Claude Code applies when the model itself drives reads. + ## Debugging - Pass `-v` / `--verbose` to Emanote and the underlying `mcp` library will print one `[request]` / `[response]` line per JSON-RPC call to stdout. Useful when a client is misbehaving or you want to see exactly what a tool call looks like. diff --git a/emanote/CHANGELOG.md b/emanote/CHANGELOG.md index 655da283b..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** scaffolding: new `emanote run --mcp-port PORT` flag runs an in-process Model Context Protocol HTTP endpoint beside the live server. Phase 1 ships only the lifecycle handshake and empty resource/tool inventories; richer surfaces follow in later phases ([#645](https://github.com/srid/emanote/issues/645)) +- **MCP server**: new `emanote run --mcp-port PORT` flag runs an in-process Model Context Protocol HTTP endpoint beside the live server. Notebook data is exposed as read-only resources — `emanote://export/metadata` (JSON, also the discovery surface for note paths) and per-note reads via the `emanote://note/{path}` URI template. `resources/list` returns only the metadata export; clients address individual notes through the template, keeping the response size independent of notebook size. Query tools and subscriptions follow in later phases ([#645](https://github.com/srid/emanote/issues/645), [#649](https://github.com/srid/emanote/pull/649)) - Default template chrome can now be localized through `page.lang` and `template.i18n`. English remains the fallback language, and French and Chinese strings are included for the built-in navigation, search, copy buttons, labels, and error chrome (closes [#486](https://github.com/srid/emanote/issues/486), [#722](https://github.com/srid/emanote/pull/722)). - Callouts: support **nested** and **foldable** Obsidian-style callouts (`> [!type]+` / `[!type]-`), rendering as `
`/`` ([#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/emanote.cabal b/emanote/emanote.cabal index b8cad4ca4..79d891aa3 100644 --- a/emanote/emanote.cabal +++ b/emanote/emanote.cabal @@ -173,6 +173,11 @@ library Emanote Emanote.CLI Emanote.MCP + Emanote.MCP.Catalog + Emanote.MCP.Handlers + Emanote.MCP.Server + Emanote.MCP.Types + Emanote.MCP.Uri Emanote.Model Emanote.Model.Calendar Emanote.Model.Calendar.Parser diff --git a/emanote/src/Emanote.hs b/emanote/src/Emanote.hs index ae0d16d78..440467b89 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 @@ -67,17 +68,35 @@ modelUpdateCachedFields model = defaultEmanoteConfig :: CLI.Cli -> EmanoteConfig defaultEmanoteConfig cli = - EmanoteConfig cli id defaultEmanotePandocRenderers False + EmanoteConfig + { _emanoteConfigCli = cli + , _emanoteConfigNoteFn = id + , _emanoteConfigPandocRenderers = defaultEmanotePandocRenderers + , _emanoteCompileTailwind = False + } run :: EmanoteConfig -> IO () run cfg@EmanoteConfig {..} = do case CLI.cmd _emanoteConfigCli of CLI.Cmd_Run runCmd -> do - let emaCfg = SiteConfig (toEmaCli (CLI.Cmd_Run runCmd)) def - ema = Ema.runSiteWith @SiteRoute emaCfg cfg >>= postRun cfg - case CLI.runMcpPort runCmd of - Nothing -> ema - Just port -> race_ (MCP.run port (CLI.verbose _emanoteConfigCli)) ema + let emaCli = toEmaCli (CLI.Cmd_Run runCmd) + emaCfg = SiteConfig emaCli def + flip runLoggerLoggingT (Ema.CLI.getLogger emaCli) $ do + rawDyn <- siteInput @SiteRoute (Ema.CLI.action emaCli) cfg + case CLI.runMcpPort runCmd of + Nothing -> + Ema.runSiteWithInput @SiteRoute emaCfg rawDyn >>= liftIO . postRun cfg + Just port -> do + -- `currentValue` seeds an IORef with the Dynamic's initial value + -- before returning, so `readLiveModel` is non-blocking from the + -- first call. `wrapped` must be consumed on the other arm of + -- `race_` to keep `readLiveModel` advancing past that initial + -- snapshot. + (readEma, wrapped) <- currentValue rawDyn + let readLiveModel = unModelEma <$> readEma + race_ + (liftIO $ MCP.run port (CLI.verbose _emanoteConfigCli) readLiveModel) + (Ema.runSiteWithInput @SiteRoute emaCfg wrapped >>= liftIO . postRun cfg) CLI.Cmd_Gen dest -> do let emaCfg = SiteConfig (toEmaCli (CLI.Cmd_Gen dest)) def Ema.runSiteWith @SiteRoute emaCfg cfg >>= postRun cfg diff --git a/emanote/src/Emanote/MCP.hs b/emanote/src/Emanote/MCP.hs index b3387936d..c9913207e 100644 --- a/emanote/src/Emanote/MCP.hs +++ b/emanote/src/Emanote/MCP.hs @@ -1,106 +1,21 @@ -{-# LANGUAGE DuplicateRecordFields #-} -{-# LANGUAGE NamedFieldPuns #-} -{-# OPTIONS_GHC -Wno-orphans #-} +{- | MCP (Model Context Protocol) server for Emanote. -{- | MCP (Model Context Protocol) server. +Exposes the notebook model as read-only MCP resources: -Runs alongside the Emanote live server in the same process, exposing a -read-only surface over HTTP. Advertises the @resources@ and @tools@ -capabilities; current handlers return empty inventories and a -not-found reply for unknown URIs. +* @emanote:\/\/export\/metadata@ — JSON metadata for every note +* @emanote:\/\/export\/content@ — all notes concatenated as a single Markdown document +* @emanote:\/\/note\/{path}@ — an individual note by its source path + +Umbrella module. Implementation lives in: + +* "Emanote.MCP.Types" — package-level type-family instances +* "Emanote.MCP.Catalog" — notebook resource catalog (what's available, how to read it) +* "Emanote.MCP.Uri" — URI wire schema and 'ResourceKind' \<-\> URI translation +* "Emanote.MCP.Handlers" — request handlers adapting the catalog to MCP wire types +* "Emanote.MCP.Server" — Warp setup, server identity, capabilities, instructions -} module Emanote.MCP ( run, ) where -import Data.Version (showVersion) -import MCP.Server ( - Implementation (..), - ListResourcesResult (..), - LoggingLevel (..), - MCPHandlerState, - MCPHandlerUser, - MCPServerState (..), - ProcessResult (..), - ReadResourceParams (..), - ResourcesCapability (..), - ServerCapabilities (..), - ToolsCapability (..), - defaultProcessHandlers, - initMCPServerState, - listResourcesHandler, - readResourceHandler, - simpleHttpApp, - withToolHandlers, - ) -import MCP.Server qualified as MCP -import Network.Wai.Handler.Warp qualified as Warp -import Paths_emanote qualified -import Relude -import System.IO (hPutStrLn) - -type instance MCPHandlerState = () - --- | Unused: 'simpleHttpApp' bypasses the JWT pipeline that would consume this. -type instance MCPHandlerUser = () - -{- | Start the MCP HTTP server on the given port. - -This blocks. Intended to be run concurrently with the Emanote live -server via 'UnliftIO.Async.race_'. Prints a single @listening@ line -to stderr once Warp has bound the socket. When @verbose@ is set, the -underlying @mcp@ library emits one line per request/response to -stdout. --} -run :: Int -> Bool -> IO () -run port verbose = do - stateVar <- - newMVar - (initMCPServerState () Nothing Nothing capabilities implementation instructions handlers) - { mcp_log_level = Just (if verbose then Debug else Warning) - } - let settings = - Warp.defaultSettings - & Warp.setPort port - & Warp.setBeforeMainLoop - (hPutStrLn stderr $ "[mcp] listening on http://localhost:" <> show port <> "/mcp") - Warp.runSettings settings (simpleHttpApp stateVar) - -implementation :: Implementation -implementation = - Implementation - { MCP.name = "emanote" - , version = toText $ showVersion Paths_emanote.version - , title = Just "Emanote MCP Server" - } - -instructions :: Maybe Text -instructions = Just "Emanote notebook exposed over MCP." - -capabilities :: ServerCapabilities -capabilities = - ServerCapabilities - { logging = Nothing - , prompts = Nothing - , resources = Just ResourcesCapability {listChanged = Nothing, subscribe = Nothing} - , tools = Just ToolsCapability {listChanged = Nothing} - , completions = Nothing - , experimental = Nothing - } - -handlers :: MCP.ProcessHandlers -handlers = - withToolHandlers - [] - defaultProcessHandlers - { listResourcesHandler = Just $ \_ -> - pure - $ ProcessSuccess - $ ListResourcesResult - { resources = [] - , nextCursor = Nothing - , MCP._meta = Nothing - } - , readResourceHandler = Just $ \ReadResourceParams {uri} -> - pure $ ProcessRPCError 404 $ "Resource not found: " <> uri - } +import Emanote.MCP.Server (run) diff --git a/emanote/src/Emanote/MCP/Catalog.hs b/emanote/src/Emanote/MCP/Catalog.hs new file mode 100644 index 000000000..25079d00b --- /dev/null +++ b/emanote/src/Emanote/MCP/Catalog.hs @@ -0,0 +1,136 @@ +{- | Notebook resource catalog consumed by "Emanote.MCP". + +Answers two questions: + +* /What/ is available? — 'listResources' returns catalog entries for + the static metadata export. +* /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.MCP.Catalog ( + ResourceKind (..), + NotebookResource (..), + ResourceBody (..), + CatalogError (..), + kindMime, + listResources, + staticResources, + readResource, +) where + +import Emanote.Model (Model) +import Emanote.Model qualified as M +import Emanote.Model.Note qualified as Note +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 + | -- | Individual note by source-relative path (e.g. @guide/mcp.md@). + Note FilePath + deriving stock (Show, Eq) + +{- | MIME type of a resource, derived from its kind. + +__Complexity:__ /O(1)/. +-} +kindMime :: ResourceKind -> Text +kindMime = \case + MetadataJson -> "application/json" + Note {} -> "text/markdown" + +-- | Catalog entry. URI-free by design; consumers assign addressing. +data NotebookResource = NotebookResource + { resourceKind :: ResourceKind + , resourceName :: Text + , resourceTitle :: Maybe Text + , resourceDescription :: Maybe Text + } + +-- | 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 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 +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 = + [ NotebookResource + { 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. Use this to discover note paths, then read individual notes via the emanote://note/{path} template." + } + ] + +{- | 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. +* @'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). +-} +readResource :: Model -> ResourceKind -> IO (Either CatalogError ResourceBody) +readResource model = \case + MetadataJson -> + pure $ Right $ ResourceBody (decodeUtf8 (ExportJSON.renderJSONExport model)) + Note path -> + case parseNoteRoute path >>= (`Note.lookupNotesByRoute` (model ^. M.modelNotes)) of + Nothing -> pure $ Left NotFound + Just note -> do + mContent <- ExportContent.readNoteContent note + 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 = + mkLMLRouteFromKnownFilePath Md fp <|> mkLMLRouteFromKnownFilePath Org fp diff --git a/emanote/src/Emanote/MCP/Handlers.hs b/emanote/src/Emanote/MCP/Handlers.hs new file mode 100644 index 000000000..9888b544b --- /dev/null +++ b/emanote/src/Emanote/MCP/Handlers.hs @@ -0,0 +1,139 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} + +{- | 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'. + +__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(log N + |note|)/ for a single note). + +No caching: each call re-runs against the live model. +-} +module Emanote.MCP.Handlers ( + handlers, + allKindShapes, + templateFor, +) where + +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) +import MCP.Server ( + ListResourceTemplatesResult (..), + ListResourcesResult (..), + ProcessResult (..), + ReadResourceParams (..), + ReadResourceResult (..), + Resource (..), + ResourceContents (..), + ResourceTemplate (..), + TextResourceContents (..), + defaultProcessHandlers, + listResourceTemplatesHandler, + listResourcesHandler, + readResourceHandler, + withToolHandlers, + ) +import MCP.Server qualified as MCP +import Relude + +handlers :: IO Model -> MCP.ProcessHandlers +handlers readModel = + withToolHandlers [] + $ defaultProcessHandlers + { listResourcesHandler = Just $ \_ -> + pure + $ ProcessSuccess + $ ListResourcesResult + { resources = toMcpResource <$> Catalog.listResources + , nextCursor = Nothing + , MCP._meta = Nothing + } + , listResourceTemplatesHandler = Just $ \_ -> + pure + $ ProcessSuccess + $ ListResourceTemplatesResult + { resourceTemplates = mapMaybe templateFor allKindShapes + , nextCursor = Nothing + , MCP._meta = Nothing + } + , readResourceHandler = Just $ \ReadResourceParams {uri} -> + case uriToKind uri of + Nothing -> pure $ ProcessRPCError 400 $ "Unrecognized resource URI: " <> uri + Just kind -> do + model <- liftIO readModel + 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 + } + +toMcpResource :: NotebookResource -> Resource +toMcpResource NotebookResource {resourceKind, resourceName, resourceTitle, resourceDescription} = + Resource + { MCP.uri = kindToUri resourceKind + , MCP.name = resourceName + , MCP.title = resourceTitle + , MCP.description = resourceDescription + , MCP.mimeType = Just (kindMime resourceKind) + , 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 + } + +{- | 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, 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. +-} +templateFor :: ResourceKind -> Maybe ResourceTemplate +templateFor = \case + MetadataJson -> 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 + } diff --git a/emanote/src/Emanote/MCP/Server.hs b/emanote/src/Emanote/MCP/Server.hs new file mode 100644 index 000000000..72cc04dbd --- /dev/null +++ b/emanote/src/Emanote/MCP/Server.hs @@ -0,0 +1,94 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} + +{- | 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.Catalog (NotebookResource (..)) +import Emanote.MCP.Catalog qualified as Catalog +import Emanote.MCP.Handlers (allKindShapes, handlers, templateFor) +import Emanote.MCP.Types () +import Emanote.MCP.Uri (kindToUri) +import Emanote.Model (Model) +import MCP.Server ( + Implementation (..), + LoggingLevel (..), + MCPServerState (..), + ResourceTemplate (..), + ResourcesCapability (..), + ServerCapabilities (..), + 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:"] + ++ (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 = + ServerCapabilities + { logging = Nothing + , prompts = Nothing + , resources = Just ResourcesCapability {listChanged = Nothing, subscribe = Nothing} + , tools = Nothing + , completions = Nothing + , experimental = Nothing + } 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 = () diff --git a/emanote/src/Emanote/MCP/Uri.hs b/emanote/src/Emanote/MCP/Uri.hs new file mode 100644 index 000000000..aee16d3f9 --- /dev/null +++ b/emanote/src/Emanote/MCP/Uri.hs @@ -0,0 +1,38 @@ +{- | 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, + noteUriPrefix, + noteUriTemplate, + uriToKind, + kindToUri, +) where + +import Data.Text qualified as T +import Emanote.MCP.Catalog (ResourceKind (..)) +import Relude + +metadataUri :: Text +metadataUri = "emanote://export/metadata" + +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 + | Just path <- T.stripPrefix noteUriPrefix uri = Just (Note (toString path)) + | otherwise = Nothing + +kindToUri :: ResourceKind -> Text +kindToUri = \case + MetadataJson -> metadataUri + Note path -> noteUriPrefix <> toText path diff --git a/justfile b/justfile index bb88021fa..1e464495a 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: