Fase 9 (2/N): daemon-hosted MCP server, retire electron/mcp_server.cljs

Completes Fase 9's "consolidar MCP" half: the daemon now serves its own
MCP endpoint at /mcp, exposing every semantic-routes operation as an MCP
tool against the daemon's authoritative graph, and the old Electron-local
MCP server is retired since it's now redundant.

New: src/main/frontend/worker/mcp_server.cljs
The CF-Worker's own MCP endpoint (deps/db-sync/worker/entry.mjs) generates
its tools from the OpenAPI document via @cloudflare/codemode's
openApiMcpServer, a package that only exists inside Cloudflare Workers,
plus a pile of ChatGPT-App-specific tooling (asset image resources,
upload_asset, Cognito protected-resource metadata) this daemon has no use
for -- none of that ports to Node. So this registers one tool per
operation by hand instead, using @modelcontextprotocol/sdk directly, the
same pattern electron/mcp_server.cljs already used for its own 6 tools
(registerTool with an explicit title/description/inputSchema/handler) --
just generated from semantic-routes' operation table and operation-docs
(now public) instead of writing 35 one-off registrations by hand, so the
MCP tool surface can't drift from the HTTP routes or the OpenAPI document.
Each tool's Zod input shape is assembled from path params (auto-derived
from the route's :param segments) plus a per-operation-id map of the
extra query/body fields; a handful of shared fragments (pagination,
time filters, the recursive block-tree shape, property-value/task-status
selectors) cover the repetition across list/create/update operations.

Each tool's handler makes a loopback HTTP call to this same daemon's own
/v1/semantic/* route, forwarding the /mcp request's own Authorization
header -- reusing the already-tested HTTP-layer logic (repo-error
validation, lock-owner assertion, response shaping) instead of a second
request path directly against the thread-api. Session/transport plumbing
(handle-post/get/delete-request, the transports-by-session-id map) is
adapted from electron/mcp_server.cljs's Fastify-based version: that one
gets req/res already unwrapped-to-Node-primitives via `.raw` with the
body pre-parsed by Fastify; this one is called straight from the daemon's
own raw http.createServer callback, so req/res already are what
StreamableHTTPServerTransport wants, and the body needs parsing here
instead. No separate auth check either -- the daemon's existing
bearer-token check on every route (request-authorized? in
db_worker_node.cljs) already gates /mcp before this is reached.

Wires POST/GET/DELETE /mcp into db_worker_node.cljs's make-server cond
chain, alongside the existing /v1/semantic/* branch.

Dependencies: adds @modelcontextprotocol/sdk and zod (zod/v3 -- the SDK
doesn't work with zod 4's default export, same constraint
electron/mcp_server.cljs already worked around) to the root package.json;
previously only resources/package.json (Electron's own bundle) had them.

Retired: electron/mcp_server.cljs and its wiring (electron/server.cljs's
initialize-mcp-routes, its 3 POST/GET/DELETE /mcp route registrations,
the :mcp-enabled? config field; components/server.cljs's "copy MCP URL"
popup link; components/settings.cljs's entire AI settings tab, which had
nothing in it besides the MCP-server toggle). Electron already talks to
the daemon (Fase 3) rather than driving itself locally, so a second,
separately-configured MCP surface bolted onto Electron's own local
automation API server (which otherwise stays -- /api is a distinct,
still-used concern) was redundant with the daemon's new one. Removed the
4 associated i18n keys (server/mcp-url-copied, settings/ai,
settings.ai/enable-mcp-server[-desc]) from all ~26 locale dicts.

New: src/test/frontend/worker/mcp_server_test.cljs
Connects a real @modelcontextprotocol/sdk client over HTTP to a live
daemon's /mcp endpoint: lists all registered tools, round-trips
createPage/getPage and a recursive captureToToday block tree, and checks
a tool call against a nonexistent page surfaces as MCP error content
rather than throwing. 4 tests, 11 assertions.

Verification: all 5 shadow-cljs targets compile with 0 warnings. Full
`bb dev:test` run: the only failures are the same 7 pre-existing,
sandboxed-network-related persist_db_test.cljs failures already
documented in the Fase 8 and Fase 9 (1/N) commits -- identical test names,
identical ECONNREFUSED/Invalid URL errors. Every other shard, including
all three daemon-HTTP worker test files sharing one test.js process
(db_worker_node_test.cljs, semantic_test.cljs, mcp_server_test.cljs),
passes clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 17:07:27 +02:00
parent 196de21731
commit db7f1fac7d
34 changed files with 927 additions and 404 deletions

View File

@@ -8,6 +8,7 @@
[frontend.worker.db-worker-node-auth :as auth]
[frontend.worker.db-worker-node-lock :as db-lock]
[frontend.worker.db-worker-node-ws :as db-ws]
[frontend.worker.mcp-server :as mcp-server]
[frontend.worker.platform.node :as platform-node]
[frontend.worker.semantic-routes :as semantic-routes]
[frontend.worker.state :as worker-state]
@@ -676,6 +677,15 @@
(string/starts-with? request-path "/v1/semantic/")
(semantic-request-handler req res bound-repo proxy parsed-url request-path method)
(and (= request-path "/mcp") (= method "POST"))
(mcp-server/handle-post-request {:host host :port (effective-port port)} req res)
(and (= request-path "/mcp") (= method "GET"))
(mcp-server/handle-get-request req res)
(and (= request-path "/mcp") (= method "DELETE"))
(mcp-server/handle-delete-request req res)
(= request-path "/v1/invoke-batch")
(if (= method "POST")
(-> (p/let [body (<read-body req)

View File

@@ -0,0 +1,280 @@
(ns frontend.worker.mcp-server
"MCP server for the daemon (Fase 9), exposing the semantic API
(semantic-routes/operations) as MCP tools. The CF-Worker's equivalent
(deps/db-sync/worker/entry.mjs) auto-generates its tools from the OpenAPI
document via @cloudflare/codemode's openApiMcpServer, a package that only
exists inside Cloudflare Workers -- not portable to Node -- plus a pile of
ChatGPT-App-specific tooling (asset image resources, upload_asset,
Cognito protected-resource metadata) this daemon has no use for. So this
registers one tool per operation by hand instead, the same pattern
electron/mcp_server.cljs already used for its own 6 tools (registerTool
with an explicit title/description/inputSchema/handler) -- just generated
from semantic-routes' operation table and docs instead of writing 34
one-off registrations, so the MCP tool surface can't drift from the HTTP
routes and the OpenAPI document.
Each tool's handler makes a loopback HTTP call to this same daemon's own
/v1/semantic/* route (see db_worker_node.cljs), forwarding the
Authorization header the /mcp request itself carried -- reusing the
already-tested HTTP-layer logic (repo-error validation, lock-owner
assertion, response shaping) rather than re-implementing a second
request path directly against the thread-api."
(:require ["@modelcontextprotocol/sdk/server/mcp.js" :refer [McpServer]]
["@modelcontextprotocol/sdk/server/streamableHttp.js" :refer [StreamableHTTPServerTransport]]
["@modelcontextprotocol/sdk/types.js" :refer [isInitializeRequest]]
["zod/v3" :as z]
[clojure.string :as string]
[frontend.worker.semantic-routes :as semantic-routes]
[lambdaisland.glogi :as log]
[promesa.core :as p]))
;; Shared Zod field fragments
;; ==========================
(def ^:private pagination-fields
{:limit (-> (z/number) .int .optional (.describe "Max items per page (default 50, max 200)"))
:cursor (-> (z/string) .optional (.describe "Opaque pagination cursor from a previous response's next-cursor"))})
(def ^:private time-filter-fields
{:created-after (-> (z/number) .int .optional (.describe "Unix epoch ms; only entities created strictly after this"))
:updated-after (-> (z/number) .int .optional (.describe "Unix epoch ms; only entities updated strictly after this"))})
(def ^:private listing-fields (merge pagination-fields time-filter-fields))
(def ^:private block-tree-schema
"Self-recursive, so it needs z/lazy -- the thunk closes over this same var,
resolved once def completes, the standard cljs recursive-def idiom."
(z/lazy (fn []
(z/object #js {:title (z/string)
:children (-> (z/array block-tree-schema) .optional)}))))
(def ^:private property-value-schema
(-> (z/union #js [(z/string) (z/number) (z/boolean)
(z/array (z/union #js [(z/string) (z/number)]))])
(.describe (str "For ref properties, an existing entity's UUID, ident, or title. Status/Priority also "
"accept built-in aliases like TODO or HIGH. Class properties like Tags accept one "
"selector or an array of them. Scalar properties take their JSON value directly."))))
(def ^:private task-status-field
(-> (z/string) .optional
(.describe "todo|doing|in-review|done|canceled|backlog, or an existing Status choice's UUID/ident/title")))
(def ^:private task-priority-field
(-> (z/string) .optional
(.describe "low|medium|high|urgent, or an existing Priority choice's UUID/ident/title")))
(def ^:private property-type-field
(-> (z/enum #js ["default" "number" "date" "datetime" "checkbox" "url" "node" "asset" "map" "json" "string"])
.optional))
(def ^:private property-cardinality-field
(-> (z/enum #js ["db.cardinality/one" "db.cardinality/many"]) .optional))
;; Per-operation extra fields (beyond the path params every operation gets
;; automatically -- see path-arg-fields below)
;; ============================================================================
(def ^:private extra-fields
{"listPages" listing-fields
"createPage" {:title (z/string)}
"listPageBlocks" listing-fields
"listPageReferences" listing-fields
"updatePage" {:title (z/string)}
"updateBlock" {:title (z/string)}
"moveBlocks" {:block-ids (z/array (z/string))
:target-id (z/string)
:position (z/enum #js ["before" "after" "first-child" "last-child"])}
"insertBlockChildren" {:position (z/enum #js ["append" "prepend"])
:blocks (z/array block-tree-schema)}
"insertBlockTree" {:target-id (z/string)
:position (-> (z/enum #js ["append" "prepend"]) .optional)
:blocks (z/array block-tree-schema)}
"setBlockProperty" {:value property-value-schema}
"batchSetBlockProperty" {:isResetExistingValues (-> (z/boolean) .optional)
:entries (z/array (z/object #js {:block-id (z/string)
:property-id (z/string)
:value property-value-schema}))}
"batchDeleteBlockProperty" {:entries (z/array (z/object #js {:block-id (z/string) :property-id (z/string)}))}
"captureToToday" {:blocks (z/array block-tree-schema)}
"listTasks" (merge listing-fields
{:status task-status-field
:priority task-priority-field
:content (-> (z/string) .optional (.describe "Case-insensitive task title filter"))})
"createTask" {:title (z/string)
:page-id (-> (z/string) .optional (.describe "Destination page UUID; omit for today's journal"))
:status task-status-field
:priority task-priority-field
:scheduled (-> (z/number) .int .optional (.describe "Scheduled date as YYYYMMDD"))
:deadline (-> (z/number) .int .optional (.describe "Deadline date as YYYYMMDD"))}
"listTags" listing-fields
"createTag" {:title (z/string)}
"listTagObjects" listing-fields
"updateTag" {:title (z/string)}
"listProperties" listing-fields
"createProperty" {:title (z/string) :type property-type-field :cardinality property-cardinality-field}
"updateProperty" {:title (-> (z/string) .optional) :type property-type-field :cardinality property-cardinality-field}
"listAssets" listing-fields
"createAsset" {:file-name (z/string)
:size (-> (z/number) .int (.describe "Exact decoded byte size of the file, max 100MB"))
:checksum (-> (z/string) (.regex (js/RegExp. "^[0-9a-fA-F]{64}$"))
(.describe "SHA-256 hex digest of the decoded file"))
:title (-> (z/string) .optional)
:page-id (-> (z/string) .optional (.describe "Destination page UUID; omit for today's journal"))
:content (-> (z/string) (.describe "Base64-encoded file contents"))}
"searchGraph" (merge listing-fields
{:q (z/string)
:types (-> (z/string) .optional
(.describe "Comma-separated subset of blocks,tags,properties,assets"))})})
;; Request construction
;; ====================
(defn- path-param-names [path]
(mapv second (re-seq #":([^/]+)" path)))
(defn- path-arg-fields [path]
(into {}
(map (fn [param]
[(keyword param) (-> (z/string) (.describe (str "The " param)))]))
(path-param-names path)))
(defn- build-path [path args]
(reduce (fn [p param]
(string/replace p (str ":" param) (js/encodeURIComponent (str (get args (keyword param))))))
path
(path-param-names path)))
(defn- query-string [args]
(let [pairs (->> args
(remove (fn [[_ v]] (nil? v)))
(map (fn [[k v]] (str (js/encodeURIComponent (name k)) "=" (js/encodeURIComponent (str v))))))]
(if (seq pairs) (str "?" (string/join "&" pairs)) "")))
(defn- request-for
[{:keys [operation-id method path]} args]
(let [path-names (set (map keyword (path-param-names path)))
rest-args (apply dissoc args path-names)
url-path (build-path path args)]
(cond
(= operation-id "createAsset")
{:method method
:path (str url-path (query-string (assoc (dissoc rest-args :content) :encoding "base64")))
:body (:content rest-args)
:content-type "text/plain"}
(contains? #{"GET" "DELETE"} method)
{:method method :path (str url-path (query-string rest-args)) :body nil :content-type nil}
:else
{:method method :path url-path :body (js/JSON.stringify (clj->js rest-args)) :content-type "application/json"})))
(defn- call-semantic-operation!
[{:keys [host port authorization]} operation args]
(let [{:keys [method path body content-type]} (request-for operation args)
url (str "http://" host ":" port path)]
(-> (js/fetch url (clj->js (cond-> {:method method
:headers (cond-> {}
authorization (assoc "Authorization" authorization)
content-type (assoc "Content-Type" content-type))}
body (assoc :body body))))
(p/then (fn [^js res]
(p/let [text (.text res)]
{:status (.-status res) :ok (.-ok res) :text text}))))))
(defn- mcp-result [{:keys [ok text]}]
#js {:content #js [#js {:type "text" :text (if (string/blank? text) "{}" text)}]
:isError (not ok)})
(defn- mcp-error-result [error]
#js {:content #js [#js {:type "text" :text (str "Unexpected MCP tool error: " (or (.-message error) error))}]
:isError true})
;; Server construction
;; ===================
(defn- register-operation-tool!
[^js mcp-server ctx operation]
(let [{:keys [operation-id]} operation
[title description] (get semantic-routes/operation-docs operation-id)
input-shape (clj->js (merge (path-arg-fields (:path operation)) (get extra-fields operation-id {})))]
(.registerTool mcp-server operation-id
#js {:title title
:description description
:inputSchema input-shape}
(fn [args]
(-> (call-semantic-operation! ctx operation (js->clj args :keywordize-keys true))
(p/then mcp-result)
(p/catch mcp-error-result))))))
(defn create-mcp-api-server
[ctx]
(let [mcp-server (McpServer. #js {:name "Logseq Daemon Semantic API" :version "1.0.0"})]
(doseq [operation semantic-routes/operations]
(register-operation-tool! mcp-server ctx operation))
mcp-server))
;; HTTP glue -- adapted from electron/mcp_server.cljs's Fastify-based version:
;; that one gets req/res already wrapped (`.raw` unwraps back to the
;; underlying Node primitives, `.body` is pre-parsed by Fastify's body
;; parser); the daemon calls this straight from its own raw
;; http.createServer callback, so req/res ARE already the Node primitives
;; StreamableHTTPServerTransport wants, and the body needs parsing here.
;; Auth is the daemon's existing bearer-token check on every route (see
;; db_worker_node.cljs's request-authorized?) -- already done by the time
;; this is reached, so unlike the CF-Worker/Electron versions there's no
;; separate auth check in here.
(defonce ^:private *transports (atom {}))
(defn- <read-body [^js req]
(p/create
(fn [resolve reject]
(let [chunks (array)]
(.on req "data" (fn [chunk] (.push chunks chunk)))
(.on req "end" (fn [] (resolve (.toString (js/Buffer.concat chunks) "utf8"))))
(.on req "error" reject)))))
(defn handle-post-request
[{:keys [host port]} ^js req ^js res]
(let [session-id (aget (.-headers req) "mcp-session-id")
authorization (.. req -headers -authorization)]
(p/let [body-text (<read-body req)
body (when-not (string/blank? body-text) (js/JSON.parse body-text))]
(cond
(and session-id (@*transports session-id))
(let [^js transport (@*transports session-id)]
(.handleRequest transport req res body))
(and (not session-id) (isInitializeRequest body))
(let [transport (StreamableHTTPServerTransport.
#js {:sessionIdGenerator (comp str random-uuid)
:enableDnsRebindingProtection true
:allowedHosts #js [(str host ":" port)]})
mcp-server (create-mcp-api-server {:host host :port port :authorization authorization})]
(set! (.-onclose transport)
(fn [] (swap! *transports dissoc (.-sessionId transport))))
(p/do!
(.connect mcp-server transport)
(.handleRequest transport req res body)
(if (.-sessionId transport)
(swap! *transports assoc (.-sessionId transport) transport)
(log/error :mcp-server-no-session-id-after-initialize nil))))
:else
(do
(.writeHead res 400 #js {"Content-Type" "application/json"})
(.end res (js/JSON.stringify
#js {:jsonrpc "2.0"
:error #js {:code -32000 :message "Bad Request: No valid session ID provided"}
:id nil})))))))
(defn handle-get-request
[^js req ^js res]
(let [session-id (aget (.-headers req) "mcp-session-id")]
(if-let [^js transport (and session-id (@*transports session-id))]
(.handleRequest transport req res)
(do (.writeHead res 400) (.end res "Invalid or missing session ID")))))
(defn handle-delete-request
[^js req ^js res]
(let [session-id (aget (.-headers req) "mcp-session-id")]
(if-let [^js transport (and session-id (@*transports session-id))]
(do (.close transport)
(.writeHead res 200 #js {"Content-Type" "application/json"})
(.end res (js/JSON.stringify #js {:ok true})))
(do (.writeHead res 400) (.end res "Invalid or missing session ID")))))

View File

@@ -83,7 +83,10 @@
{:method "GET" :path "/v1/semantic/search"
:handler :semantic/search :operation-id "searchGraph" :success-status 200}])
(def ^:private operation-docs
(def operation-docs
"Public so worker.mcp-server can source MCP tool titles/descriptions from
the same place the OpenAPI document does, instead of a second copy that
could drift."
{"listPages" ["List pages" "Returns a cursor-paginated list of page blocks in the graph."]
"createPage" ["Create a page" "Creates a page block with the supplied title."]
"listPageBlocks" ["List a page's blocks" "Returns a cursor-paginated list of the page's top-level blocks, including each selected block's descendant tree."]