enhance: batch update nodes tool

This improves and replaces all previous tools. Features include:
* Add pages, blocks to pages, tags and properties
* Tags can set parents and tag properties
* Properties can have type, cardinality and classes set for :node
* Add tags to blocks
* Edit blocks
* api and local tools work
* Thorough tool validation
* When doing these operations, most operations can reference new or existing
  entities if they are referenced by uuid
This commit is contained in:
Gabriel Horner
2025-10-10 16:02:41 -04:00
parent bfcc3590ac
commit a586fc47fb
6 changed files with 369 additions and 15 deletions

2
deps/cli/bb.edn vendored
View File

@@ -40,5 +40,5 @@
:tasks/config
{:large-vars
{:max-lines-count 45
{:max-lines-count 50
:metadata-exceptions #{:large-vars/cleanup-todo}}}}

View File

@@ -24,12 +24,21 @@
(defn- local-list-tags [conn _args]
(cli-common-mcp-server/mcp-success-response (cli-common-mcp-tools/list-tags @conn)))
(defn- local-upsert-nodes [conn args]
(cli-common-mcp-server/mcp-success-response
(cli-common-mcp-tools/upsert-nodes
conn
;; string is used by a -t invocation
(-> (if (string? (.-operations args)) (js/JSON.parse (.-operations args)) (.-operations args))
(js->clj :keywordize-keys true)))))
(def ^:private local-tools
"MCP Tools when running with a local graph"
(let [tools {:getPage {:fn local-get-page}
:listPages {:fn local-list-pages}
:listProperties {:fn local-list-properties}
:listTags {:fn local-list-tags}}]
:listTags {:fn local-list-tags}
:upsertNodes {:fn local-upsert-nodes}}]
(merge-with
merge
(select-keys cli-common-mcp-server/api-tools (keys tools))

View File

@@ -16,7 +16,7 @@
;; for how to respond to different MCP requests
(defn handle-post-request [mcp-server {:keys [port host]} req res]
(let [session-id (aget (.-headers req) "mcp-session-id")]
(js/console.log "POST /mcp request" session-id (.-body req))
(js/console.log "POST /mcp request" session-id (pr-str (.-body req)))
(cond
(and session-id (@transports session-id))
(let [^js transport (@transports session-id)]
@@ -131,7 +131,11 @@
[call-api-fn args]
(call-api-fn "logseq.app.search" [(aget args "searchTerm") #js {:enable-snippet? false}]))
(def api-tools
(defn- api-upsert-nodes
[call-api-fn args]
(call-api-fn "logseq.cli.upsertNodes" [(aget args "operations")]))
(def ^:large-vars/data-var api-tools
"MCP Tools when calling API server"
{:listPages
{:fn api-list-pages
@@ -140,7 +144,7 @@
:getPage
{:fn api-get-page
:config #js {:title "Get Page"
:description "Get a page's content including its blocks"
:description "Get a page's content including its blocks. A property and a tag are pages."
:inputSchema #js {:pageName (-> (z/string) (.describe "The page's name or uuid"))}}}
:addToPage
{:fn api-add-to-page
@@ -157,6 +161,67 @@
:description "Update block with new content"
:inputSchema #js {:blockUUID (z/string)
:content (-> (z/string) (.describe "Block content"))}}}
:upsertNodes
{:fn api-upsert-nodes
:config
#js {:title "Upsert Nodes"
:description
"Takes an object with field :operations, which is an array of operation objects.
Each operation creates or edits a page, block, tag or property. Each operation is a object
that must have :operation, :entityType and :data fields. More about fields in an operation object:
* :operation - Either :add or :edit
* :entityType - What type of node, e.g. :block, :page, :tag or :property
* :id - For :edit, this _must_ be a string uuid. For :add, use a temporary unique string if the new page is referenced by later operations e.g. add blocks
* :data - A map of fields to set or update. This map can have the following keys:
* :title - A page/tag/property's name or a block's content
* :page-id - A page string uuid of a block. Required when entityType is :block.
* :tags - A list of tags as string uuids
* :property-type - A property's type
* :property-cardinality - A property's cardinality. Must be :one or :many
* :property-classes - A property's list of allowed tags, each being a uuid string or a tag's name
* :class-extends - List of parent tags, each being a uuid string or a tag's name
* :class-properties - A tag's list of properties, each eing a uuid string or a property's name
Example inputs with their prompt, description and data as clojure EDN:
Description: This input adds a new block to page with id '119268a6-704f-4e9e-8c34-36dfc6133729' and update the title of a page with uuid '119268a6-704f-4e9e-8c34-36dfc6133729':
{:operations
[{:operation :add
:entityType :block
:id nil
:data {:page-id \"119268a6-704f-4e9e-8c34-36dfc6133729\"
:title \"New block text\"}}
{:operation :edit
:entity :page
:id \"119268a6-704f-4e9e-8c34-36dfc6133729\"
:data {:title \"Revised page title\"}}]}
Prompt: Add task 't1' to new page 'Inbox'
Description: This input creates a page 'Inbox' and adds a 't1' block with tag \"00000002-1282-1814-5700-000000000000\" (task) to it:
{:operations
[{:operation :add
:entityType :page
:id \"temp-Inbox\"
:data {:title \"Inbox\"}}
{:operation :add
:entityType :block
:data {:page-id \"temp-Inbox\"
:title \"t1\"
:tags [\"00000002-1282-1814-5700-000000000000\"]}}]}
Additional advice for building operations:
* When building a block update operation, use the 'page' key of the searchBlocks tool to fill in the value of :page-id under :data
* Before creating any page, tag or property, check that it exists with getPage"
:inputSchema
#js {:operations
(z/array
(z/object
#js {:operation (z/enum #js ["add" "edit"])
:entityType (z/enum #js ["block" "page" "tag" "property"])
:id (.optional (z/union #js [(z/string) (z/number) (z/null)]))
:data (-> (z/object #js {}) (.passthrough))}))}}}
:searchBlocks
{:fn api-search-blocks
:config #js {:title "Search Blocks"

View File

@@ -1,10 +1,19 @@
(ns logseq.cli.common.mcp.tools
"MCP tool related fns shared between CLI and frontend"
(:require [datascript.core :as d]
(:require [clojure.string :as string]
[datascript.core :as d]
[logseq.common.util :as common-util]
[logseq.common.util.date-time :as date-time-util]
[logseq.db :as ldb]
[logseq.db.frontend.class :as db-class]
[logseq.db.frontend.entity-util :as entity-util]
[logseq.db.frontend.property :as db-property]
[logseq.outliner.tree :as otree]))
[logseq.db.frontend.property.type :as db-property-type]
[logseq.db.sqlite.export :as sqlite-export]
[logseq.outliner.tree :as otree]
[logseq.outliner.validate :as outliner-validate]
[malli.core :as m]
[malli.error :as me]))
(defn list-properties
"Main fn for ListProperties tool"
@@ -83,4 +92,264 @@
;; 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)))))
(update :block/uuid str)))))
;; upsert-nodes tool
;; =================
(defn- import-edn-data
[conn export-map]
(let [{:keys [init-tx block-props-tx misc-tx error] :as _txs}
(sqlite-export/build-import export-map @conn {})]
;; (cljs.pprint/pprint _txs)
(when error
(throw (ex-info (str "Error while building import data: " error) {})))
(let [tx-meta {::sqlite-export/imported-data? true
:import-db? true}]
(ldb/transact! conn (vec (concat init-tx block-props-tx misc-tx)) tx-meta))))
(defn- get-ident [idents title]
(or (get idents title)
(throw (ex-info (str "No ident found for " (pr-str title)) {}))))
(defn- ops->pages-and-blocks
[operations {:keys [class-idents]}]
(let [blocks-by-page
(group-by #(get-in % [:data :page-id])
(filter #(= "block" (:entityType %)) operations))
new-pages (filter #(and (= "page" (:entityType %)) (= "add" (:operation %))) operations)
pages-and-blocks
(into (mapv (fn [op]
(cond-> {:page (if-let [journal-day (date-time-util/journal-title->int
(get-in op [:data :title])
;; consider user's date-formatter as needed
(date-time-util/safe-journal-title-formatters nil))]
{:build/journal journal-day}
{:block/title (get-in op [:data :title])})}
(some-> (:id op) (get blocks-by-page))
(assoc :blocks
(mapv #(hash-map :block/title (get-in % [:data :title]))
(get blocks-by-page (:id op))))))
new-pages)
;; existing pages
(map (fn [[page-id ops]]
{:page {:block/uuid (uuid page-id)}
:blocks (mapv (fn [op]
(if (= "add" (:operation op))
(cond-> {:block/title (get-in op [:data :title])}
(get-in op [:data :tags])
(assoc :build/tags (mapv #(get-ident class-idents %) (get-in op [:data :tags]))))
;; edit
(cond-> {:block/uuid (uuid (:id op))}
(get-in op [:data :title])
(assoc :block/title (get-in op [:data :title])))))
ops)})
(apply dissoc blocks-by-page (map :id new-pages))))]
pages-and-blocks))
(defn- ops->classes
[operations {:keys [property-idents class-idents existing-idents]}]
(let [new-classes (filter #(and (= "tag" (:entityType %)) (= "add" (:operation %))) operations)
classes (merge
(into {} (keep (fn [[k v]]
;; Removing existing until edits are supported
(when-not (existing-idents v) [v {:block/title k}]))
class-idents))
(->> new-classes
(map (fn [{:keys [data] :as op}]
(let [title (get-in op [:data :title])
class-m (cond-> {:block/title title}
(:class-extends data)
(assoc :build/class-extends (mapv #(get-ident class-idents %) (:class-extends data)))
(:class-properties data)
(assoc :build/class-properties (mapv #(get-ident property-idents %) (:class-properties data))))]
[(get-ident class-idents title) class-m])))
(into {})))]
classes))
(defn- ops->properties
[operations {:keys [property-idents class-idents existing-idents]}]
(let [new-properties (filter #(and (= "property" (:entityType %)) (= "add" (:operation %))) operations)
properties
(merge
(into {} (map (fn [[k v]]
;; Removing existing until edits are supported
(when-not (existing-idents v) [v {:block/title k}]))
property-idents))
(->> new-properties
(map (fn [{:keys [data] :as op}]
(let [title (get-in op [:data :title])
prop-m (cond-> {:block/title title}
(some->> (:property-type data) keyword (contains? (set db-property-type/user-built-in-property-types)))
(assoc :logseq.property/type (keyword (:property-type data)))
(= "many" (:property-cardinality data))
(assoc :db/cardinality :db.cardinality/many)
(:property-classes data)
(assoc :build/property-classes
(mapv #(get-ident class-idents %) (:property-classes data))
:logseq.property/type :node))]
[(get-ident property-idents title) prop-m])))
(into {})))]
properties))
(defn- operations->idents
"Creates property and class idents from all uses of them in operations"
[db operations]
(let [existing-idents (atom #{})
property-idents
(->> (filter #(and (= "property" (:entityType %)) (= "add" (:operation %)))
operations)
(map #(get-in % [:data :title]))
(into (mapcat #(get-in % [:data :class-properties])
(filter #(and (= "tag" (:entityType %)) (= "add" (:operation %)))
operations)))
distinct
(map #(vector % (if (common-util/uuid-string? %)
(let [ent (d/entity db [:block/uuid (uuid %)])
ident (:db/ident ent)]
(when-not (entity-util/property? ent)
(throw (ex-info (str (pr-str (:block/title ent))
" is not a property and can't be used as one")
{})))
(swap! existing-idents conj ident)
ident)
(db-property/create-user-property-ident-from-name %))))
(into {}))
class-idents
(->> (filter #(and (= "tag" (:entityType %)) (= "add" (:operation %))) operations)
(mapcat (fn [op]
(into [(get-in op [:data :title])] (get-in op [:data :class-extends]))))
(into (mapcat #(get-in % [:data :property-classes])
(filter #(and (= "property" (:entityType %)) (= "add" (:operation %)))
operations)))
(into (mapcat #(get-in % [:data :tags])
(filter #(and (= "block" (:entityType %)) (= "add" (:operation %)))
operations)))
distinct
(map #(vector % (if (common-util/uuid-string? %)
(let [ent (d/entity db [:block/uuid (uuid %)])
ident (:db/ident ent)]
(when-not (entity-util/class? ent)
(throw (ex-info (str (pr-str (:block/title ent))
" is not a tag and can't be used as one")
{})))
(swap! existing-idents conj ident)
ident)
(db-class/create-user-class-ident-from-name db %))))
(into {}))]
{:property-idents property-idents
:class-idents class-idents
:existing-idents @existing-idents}))
(def ^:private add-non-block-schema
[:map
[:data [:map
[:title :string]]]])
(def ^:private uuid-string
[:and :string [:fn {:error/message "Must be a uuid string"} common-util/uuid-string?]])
(def ^:private upsert-nodes-operation-schema
[:and
;; Base schema. Has some overlap with inputSchema
[:map
{:closed true}
[:operation [:enum "add" "edit"]]
[:entityType [:enum "block" "page" "tag" "property"]]
[:id {:optional true} [:or :string :nil]]
[:data [:map
[:title {:optional true} :string]
[:page-id {:optional true} :string]
[:tags {:optional true} [:sequential uuid-string]]
[:property-type {:optional true} :string]
[:property-cardinality {:optional true} [:enum "many" "one"]]
[:property-classes {:optional true} [:sequential :string]]
[:class-extends {:optional true} [:sequential :string]]
[:class-properties {:optional true} [:sequential :string]]]]]
;; Validate special cases of operation and entityType e.g. required keys and uuid strings
[:multi {:dispatch (juxt :operation :entityType)}
[["add" "block"] [:map
[:data [:map
[:title :string]
[:page-id :string]]]]]
[["add" "page"] add-non-block-schema]
[["add" "tag"] add-non-block-schema]
[["add" "property"] add-non-block-schema]
[["edit" "block"] [:map
[:id uuid-string]
;; :tags not supported yet
[:data [:map {:closed true}
[:page-id uuid-string]
[:title :string]]]]]
;; other edit's
[::m/default [:map [:id uuid-string]]]]])
(def ^:private Upsert-nodes-operations-schema
[:sequential upsert-nodes-operation-schema])
(defn- validate-import-edn
"Validates everything as coming from add operations, failing fast on first invalid
node. Will need to adjust add operation assumption when supporting editing pages"
[{:keys [pages-and-blocks properties classes]}]
(try
(doseq [{:block/keys [title] :as m} (vals properties)]
(outliner-validate/validate-property-title title {:entity-type :property :title title :entity-map m})
(outliner-validate/validate-page-title-characters title {:entity-type :property :title title :entity-map m})
(outliner-validate/validate-page-title title {:entity-type :property :title title :entity-map m}))
(doseq [{:block/keys [title] :as m} (vals classes)]
(outliner-validate/validate-page-title-characters title {:entity-type :tag :title title :entity-map m})
(outliner-validate/validate-page-title title {:entity-type :tag :title title :entity-map m}))
(doseq [{:block/keys [title] :as m} (map :page pages-and-blocks)]
;; title is only present for new pages
(when title
(outliner-validate/validate-page-title-characters title {:entity-type :page :title title :entity-map m})
(outliner-validate/validate-page-title title {:entity-type :page :title title :entity-map m})))
(catch :default e
(js/console.error e)
(throw (ex-info (str (string/capitalize (name (get (ex-data e) :entity-type :page)))
" " (pr-str (:title (ex-data e))) " is invalid: " (ex-message e))
(ex-data e))))))
(defn- summarize-upsert-operations [operations]
(let [counts (reduce (fn [acc op]
(let [entity-type (keyword (:entityType op))
operation-type (keyword (:operation op))]
(update-in acc [operation-type entity-type] (fnil inc 0))))
{}
operations)]
(str (when (counts :add)
(str "Added: " (pr-str (counts :add)) "."))
(when (counts :edit)
(str " Edited: " (pr-str (counts :edit)) ".")))))
(defn upsert-nodes
[conn operations*]
;; Only support these operations with appropriate outliner validations
(when (seq (filter #(and (#{"page" "tag" "property"} (:entityType %)) (= "edit" (:operation %))) operations*))
(throw (ex-info "Editing a page, tag or property isn't supported yet" {})))
(let [operations
(->> operations*
;; normalize classes as they sometimes have titles in :name
(map #(if (and (= "tag" (:entityType %)) (= "add" (:operation %)))
(assoc-in % [:data :title]
(or (get-in % [:data :name]) (get-in % [:data :title])))
%)))
_ (prn :ops operations)
_ (when-let [errors (m/explain Upsert-nodes-operations-schema operations)]
(throw (ex-info (str "Tool arguments are invalid:\n" (me/humanize errors))
{:errors errors})))
idents (operations->idents @conn operations)
pages-and-blocks (ops->pages-and-blocks operations idents)
classes (ops->classes operations idents)
properties (ops->properties operations idents)
import-edn
(cond-> {}
(seq pages-and-blocks)
(assoc :pages-and-blocks pages-and-blocks)
(seq classes)
(assoc :classes classes)
(seq properties)
(assoc :properties properties))]
(prn :import-edn import-edn)
(validate-import-edn import-edn)
(import-edn-data conn import-edn)
(summarize-upsert-operations operations*)))

View File

@@ -144,12 +144,14 @@
(defn validate-property-title
"Validates a property's title when it has changed"
[new-title]
(when-not (db-property/valid-property-name? new-title)
(throw (ex-info "Property name is invalid"
{:type :notification
:payload {:message "This is an invalid property name. A property name cannot start with page reference characters '#' or '[['."
:type :error}}))))
([new-title] (validate-property-title new-title {}))
([new-title meta-m]
(when-not (db-property/valid-property-name? new-title)
(throw (ex-info "Property name is invalid"
(merge meta-m
{:type :notification
:payload {:message "This is an invalid property name. A property name cannot start with page reference characters '#' or '[['."
:type :error}}))))))
(defn- validate-extends-property-have-correct-type
"Validates whether given parent and children are classes"

View File

@@ -33,6 +33,7 @@
[frontend.handler.route :as route-handler]
[frontend.handler.search :as search-handler]
[frontend.handler.shell :as shell]
[frontend.handler.ui :as ui-handler]
[frontend.idb :as idb]
[frontend.loader :as loader]
[frontend.modules.layout.core]
@@ -60,7 +61,8 @@
[logseq.sdk.ui :as sdk-ui]
[logseq.sdk.utils :as sdk-utils]
[promesa.core :as p]
[reitit.frontend.easy :as rfe]))
[reitit.frontend.easy :as rfe]
[logseq.cli.common.mcp.tools :as cli-common-mcp-tools]))
;; Alert: this namespace shouldn't invoke any reactive queries
@@ -1198,6 +1200,13 @@
(clj->js resp)
#js {:error (str "Page " (pr-str page-title) " not found")})))
(defn ^:export upsert_nodes
"Given a list of MCP operations, batch upserts resulting EDN data"
[operations]
(p/let [resp (cli-common-mcp-tools/upsert-nodes (conn/get-db false) (js->clj operations :keywordize-keys true))]
(ui-handler/re-render-root!)
resp))
;; ui
(def ^:export show_msg sdk-ui/-show_msg)
(def ^:export query_element_rect sdk-ui/query_element_rect)