diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn index 3c419b7a49..91a1afebd0 100644 --- a/.clj-kondo/config.edn +++ b/.clj-kondo/config.edn @@ -165,10 +165,11 @@ frontend.worker.util worker-util lambdaisland.glogi log logseq.cli.common.graph cli-common-graph - logseq.cli.text-util cli-text-util logseq.cli.common.export.common cli-export-common logseq.cli.common.export.text cli-export-text logseq.cli.common.file common-file + logseq.cli.common.mcp.tools cli-common-mcp-tools + logseq.cli.text-util cli-text-util logseq.common.config common-config logseq.common.date-time-util date-time-util logseq.common.graph common-graph diff --git a/deps/cli/.carve/ignore b/deps/cli/.carve/ignore index c3a5f2260f..d8fb91dc85 100644 --- a/deps/cli/.carve/ignore +++ b/deps/cli/.carve/ignore @@ -6,3 +6,4 @@ logseq.cli.commands.query/query logseq.cli.commands.search/search logseq.cli.commands.export/export logseq.cli.commands.append/append +logseq.cli.commands.mcp-server/start diff --git a/deps/cli/.clj-kondo/config.edn b/deps/cli/.clj-kondo/config.edn index e89549e62b..53e23db7ff 100644 --- a/deps/cli/.clj-kondo/config.edn +++ b/deps/cli/.clj-kondo/config.edn @@ -12,6 +12,7 @@ clojure.string string datascript.core d logseq.cli.commands.graph cli-graph + logseq.cli.common.mcp.tools cli-common-mcp-tools logseq.cli.common.graph cli-common-graph logseq.cli.common.export.text cli-export-text logseq.cli.common.export.common cli-export-common diff --git a/deps/cli/src/logseq/cli/commands/mcp_server.cljs b/deps/cli/src/logseq/cli/commands/mcp_server.cljs index f628ffc68d..c9341b606a 100644 --- a/deps/cli/src/logseq/cli/commands/mcp_server.cljs +++ b/deps/cli/src/logseq/cli/commands/mcp_server.cljs @@ -4,14 +4,9 @@ ["@modelcontextprotocol/sdk/server/stdio.js" :refer [StdioServerTransport]] ["fs" :as fs] ["zod/v3" :as z] - [datascript.core :as d] + [logseq.cli.common.mcp.tools :as cli-common-mcp-tools] [logseq.cli.util :as cli-util] - [logseq.db :as ldb] - [logseq.db.common.initial-data :as common-initial-data] [logseq.db.common.sqlite-cli :as sqlite-cli] - [logseq.db.frontend.entity-util :as entity-util] - [logseq.db.frontend.property :as db-property] - [logseq.outliner.tree :as otree] [nbb.core :as nbb] [promesa.core :as p])) @@ -30,97 +25,62 @@ #js [#js {:type "text" :text (str "Unexpected API error: " (.-message error))}]}) -(defn- api-get-all-pages - [{{:keys [api-server-token]} :opts} _args] - (-> (p/let [resp (cli-util/api-fetch api-server-token "logseq.editor.getAllPages" [])] - (if (= 200 (.-status resp)) - (p/let [body (.json resp) - pages (map #(hash-map :title (.-title %) - :createdAt (.-createdAt %) - :updatedAt (.-updatedAt %) - :id (.-uuid %)) - body)] - (mcp-success-response pages)) - (cli-util/api-handle-error-response resp mcp-error-response))) - (p/catch unexpected-api-error))) - -(defn- api-get-page - [{{:keys [api-server-token]} :opts} args] - (-> (p/let [resp (cli-util/api-fetch api-server-token "logseq.Editor.getPageBlocksTree" [(aget args "pageName")])] +(defn- api-tool + "Calls API method w/ args and returns a MCP response" + [api-server-token api-method method-args] + (-> (p/let [resp (cli-util/api-fetch api-server-token api-method method-args)] (if (= 200 (.-status resp)) (p/let [body (.json resp)] (mcp-success-response body)) (cli-util/api-handle-error-response resp mcp-error-response))) (p/catch unexpected-api-error))) +(defn- api-get-page + [{{:keys [api-server-token]} :opts} args] + (api-tool api-server-token "logseq.cli.getPageData" [(aget args "pageName")])) + +(defn- api-list-pages + [{{:keys [api-server-token]} :opts} _args] + (api-tool api-server-token "logseq.cli.listPages" [])) + +(defn- api-list-tags + [{{:keys [api-server-token]} :opts} _args] + (api-tool api-server-token "logseq.cli.listTags" [])) + +(defn- api-list-properties + [{{:keys [api-server-token]} :opts} _args] + (api-tool api-server-token "logseq.cli.listProperties" [])) + (def ^:private api-tools "MCP Tools when running with API server" - {:getAllPages - {:fn api-get-all-pages + {:listPages + {:fn api-list-pages :config #js {:title "List Pages"}} :getPage {:fn api-get-page :config #js {:title "Get Page" - :description "Get a page's content" - :inputSchema #js {:pageName (z/string)}}}}) + :description "Get a page's content including its blocks" + :inputSchema #js {:pageName (z/string)}}} + :listTags + {:fn api-list-tags + :config #js {:title "List Tags"}} + :listProperties + {:fn api-list-properties + :config #js {:title "List Properties"}}}) (defn- local-get-page [conn args] - (let [db @conn - page-id (common-initial-data/get-first-page-by-title db (aget args "pageName")) - blocks (when page-id (ldb/get-page-blocks db page-id))] - (if page-id - (->> (otree/blocks->vec-tree "logseq_db_repo_stub" db blocks page-id) - (map #(update % :block/uuid str)) - mcp-success-response) - (mcp-error-response (str "Error: Page " (pr-str (aget args "pageName")) " not found"))))) + (if-let [blocks (cli-common-mcp-tools/get-page-blocks @conn (aget args "pageName"))] + (mcp-success-response blocks) + (mcp-error-response (str "Error: Page " (pr-str (aget args "pageName")) " not found")))) -(defn- local-get-all-pages [conn _args] - (->> (d/datoms @conn :avet :block/name) - (map #(d/entity @conn (:e %))) - (remove entity-util/hidden?) - (mapv (fn [e] - {:id (str (:block/uuid e)) - :title (:block/title e) - :createdAt (:block/created-at e) - :updatedAt (:block/updated-at e)})) - mcp-success-response)) +(defn- local-list-pages [conn _args] + (mcp-success-response (cli-common-mcp-tools/list-pages @conn))) (defn- local-list-properties [conn _args] - (->> (d/datoms @conn :avet :block/tags :logseq.class/Property) - (map #(d/entity @conn (:e %))) - #_((fn [x] (prn :prop-keys (distinct (mapcat keys x))) x)) - (map (fn [e] - (cond-> (into {} e) - true - (dissoc e :block/tags :block/order :block/refs :block/name :db/index - :logseq.property.embedding/hnsw-label-updated-at :logseq.property/default-value) - true - (update :block/uuid str) - (:logseq.property/classes e) - (update :logseq.property/classes #(mapv :db/ident %)) - (:logseq.property/description e) - (update :logseq.property/description db-property/property-value-content)))) - mcp-success-response)) + (mcp-success-response (cli-common-mcp-tools/list-properties @conn))) (defn- local-list-tags [conn _args] - (->> (d/datoms @conn :avet :block/tags :logseq.class/Tag) - (map #(d/entity @conn (:e %))) - (map (fn [e] - (cond-> (into {} e) - true - (dissoc e :block/tags :block/order :block/refs :block/name - :logseq.property.embedding/hnsw-label-updated-at) - true - (update :block/uuid str) - (:logseq.property.class/extends e) - (update :logseq.property.class/extends #(mapv :db/ident %)) - (:logseq.property.class/properties e) - (update :logseq.property.class/properties #(mapv :db/ident %)) - (:logseq.property.view/type e) - (update :logseq.property.view/type :db/ident) - (:logseq.property/description e) - (update :logseq.property/description db-property/property-value-content)))) - mcp-success-response)) + (mcp-success-response (cli-common-mcp-tools/list-tags @conn))) (def ^:private local-tools "MCP Tools when running with a local graph" @@ -128,11 +88,9 @@ merge api-tools {:getPage {:fn local-get-page} - :getAllPages {:fn local-get-all-pages} - :listProperties {:fn local-list-properties - :config #js {:title "List Properties"}} - :listTags {:fn local-list-tags - :config #js {:title "List Tags"}}})) + :listPages {:fn local-list-pages} + :listProperties {:fn local-list-properties} + :listTags {:fn local-list-tags}})) (defn start [{{:keys [debug-tool graph] :as opts} :opts :as m}] (when (and graph (not (fs/existsSync (cli-util/get-graph-dir graph)))) diff --git a/deps/cli/src/logseq/cli/common/mcp/tools.cljs b/deps/cli/src/logseq/cli/common/mcp/tools.cljs new file mode 100644 index 0000000000..5e1a6ac6f4 --- /dev/null +++ b/deps/cli/src/logseq/cli/common/mcp/tools.cljs @@ -0,0 +1,68 @@ +(ns logseq.cli.common.mcp.tools + "MCP tool related fns shared between CLI and frontend" + (:require [datascript.core :as d] + [logseq.db :as ldb] + [logseq.db.frontend.entity-util :as entity-util] + [logseq.db.common.initial-data :as common-initial-data] + [logseq.db.frontend.property :as db-property] + [logseq.outliner.tree :as otree])) + +(defn list-properties + "Main fn for ListProperties tool" + [db] + (->> (d/datoms db :avet :block/tags :logseq.class/Property) + (map #(d/entity db (:e %))) + #_((fn [x] (prn :prop-keys (distinct (mapcat keys x))) x)) + (map (fn [e] + (cond-> (into {} e) + true + (dissoc e :block/tags :block/order :block/refs :block/name :db/index + :logseq.property.embedding/hnsw-label-updated-at :logseq.property/default-value) + true + (update :block/uuid str) + (:logseq.property/classes e) + (update :logseq.property/classes #(mapv :db/ident %)) + (:logseq.property/description e) + (update :logseq.property/description db-property/property-value-content)))))) + +(defn list-tags + "Main fn for ListTags tool" + [db] + (->> (d/datoms db :avet :block/tags :logseq.class/Tag) + (map #(d/entity db (:e %))) + (map (fn [e] + (cond-> (into {} e) + true + (dissoc e :block/tags :block/order :block/refs :block/name + :logseq.property.embedding/hnsw-label-updated-at) + true + (update :block/uuid str) + (:logseq.property.class/extends e) + (update :logseq.property.class/extends #(mapv :db/ident %)) + (:logseq.property.class/properties e) + (update :logseq.property.class/properties #(mapv :db/ident %)) + (:logseq.property.view/type e) + (update :logseq.property.view/type :db/ident) + (:logseq.property/description e) + (update :logseq.property/description db-property/property-value-content)))))) + +(defn get-page-blocks + "Get page blocks for GetPage tool" + [db page-title] + (when-let [page-id (common-initial-data/get-first-page-by-title db page-title)] + (let [blocks (ldb/get-page-blocks db page-id)] + ;; Use repo stub since this is a DB only tool + (->> (otree/blocks->vec-tree "logseq_db_repo_stub" db blocks page-id) + (map #(update % :block/uuid str)))))) + +(defn list-pages + "Main fn for ListPages tool" + [db] + (->> (d/datoms db :avet :block/name) + (map #(d/entity db (:e %))) + (remove entity-util/hidden?) + (map #(-> % + ;; Until there are options to limit pages, return minimal info to avoid + ;; exceeding max payload size + (select-keys [:block/uuid :block/title :block/created-at :block/updated-at]) + (update :block/uuid str))))) \ No newline at end of file diff --git a/src/main/electron/listener.cljs b/src/main/electron/listener.cljs index e434f2058b..7621787438 100644 --- a/src/main/electron/listener.cljs +++ b/src/main/electron/listener.cljs @@ -130,7 +130,7 @@ method' (last ns-method) args (.-args data) ret-fn! #(ipc/invoke (str :electron.server/sync! sync-id) %) - app? (contains? #{"app" "editor" "db"} ns') + app? (contains? #{"app" "editor" "db" "cli"} ns') ^js sdk1 (aget js/window.logseq "api") ^js sdk2 (aget js/window.logseq "sdk")] diff --git a/src/main/logseq/api.cljs b/src/main/logseq/api.cljs index bc6ca33e93..2a5e704a63 100644 --- a/src/main/logseq/api.cljs +++ b/src/main/logseq/api.cljs @@ -48,6 +48,7 @@ [goog.object :as gobj] [lambdaisland.glogi :as log] [logseq.api.block :as api-block] + [logseq.cli.common.mcp.tools :as cli-common-mcp-tools] [logseq.common.util :as common-util] [logseq.common.util.date-time :as date-time-util] [logseq.db :as ldb] @@ -1156,6 +1157,28 @@ (when-let [args (and args (seq (bean/->clj args)))] (shell/run-git-command! args))) +;; Internal CLI API +;; TODO: Use transit for internal APIs +(defn ^:export list_tags + [] + (clj->js (cli-common-mcp-tools/list-tags (db/get-db)))) + +(defn ^:export list_properties + [] + (clj->js (cli-common-mcp-tools/list-properties (db/get-db)))) + +(defn ^:export get_page_data + "Like get_page_blocks_tree but for MCP tools" + [page-title] + (when-let [tools (cli-common-mcp-tools/get-page-blocks (db/get-db) page-title)] + (->> tools + (map #(dissoc % :block.temp/has-children? :block.temp/load-status)) + clj->js))) + +(defn ^:export list_pages + [] + (clj->js (cli-common-mcp-tools/list-pages (db/get-db)))) + ;; ui (def ^:export show_msg sdk-ui/-show_msg) (def ^:export query_element_rect sdk-ui/query_element_rect)