Note (2026-04-24): After this document was written, legal_entity was renamed to tenant and the old tenant was renamed to organization. Read references to these terms with the pre-rename meaning.

Master Data MCP Tools Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Two MCP tools for querying master data (legal entities + GL accounts / cost centers / business partners), used by agents during the Data Discovery Protocol.

Architecture: Single new file src/com/getorcha/link/mcp/tools/master_data.clj with both tools registered via tools/register-tool!. Legal entity resolution is a shared private function. JSONB array filtering and pagination happens server-side via jsonb_array_elements. Tests in test/com/getorcha/link/mcp_test.clj.

Tech Stack: Clojure, HoneySQL, next-jdbc, cheshire, embedded PostgreSQL for tests


Files:

Step 1: Write the failing test

Add to test/com/getorcha/link/mcp_test.clj:

;; orcha-master-data-legal-entities Tool Tests
;; -----------------------------------------------------------------------------

(deftest test-tool-master-data-legal-entities
  (testing "orcha-master-data-legal-entities returns accessible legal entities"
    (mcp.tools/init-tools!)
    (let [{:keys [identity legal-entity]} (create-test-identity-with-legal-entity! fixtures/*db*)
          token    (get-test-access-token fixtures/*db* identity)
          response (mcp-request {:jsonrpc "2.0"
                                 :method  "tools/call"
                                 :params  {:name      "orcha-master-data-legal-entities"
                                           :arguments {}}
                                 :id      1}
                                token)]
      (is (= 200 (:status response)))
      (let [content (get-in response [:body :result :content 0 :text])
            result  (json/parse-string content true)]
        (is (= 1 (count (:legal_entities result))))
        (is (= (str (:legal-entity/id legal-entity))
               (get-in result [:legal_entities 0 :id])))
        (is (= (:legal-entity/name legal-entity)
               (get-in result [:legal_entities 0 :name])))))))


(deftest test-tool-master-data-legal-entities-search
  (testing "orcha-master-data-legal-entities filters by name"
    (mcp.tools/init-tools!)
    (let [{:keys [identity legal-entity]} (create-test-identity-with-legal-entity! fixtures/*db*)
          le-name  (:legal-entity/name legal-entity)
          token    (get-test-access-token fixtures/*db* identity)
          ;; Search with a substring that matches
          match    (mcp-request {:jsonrpc "2.0"
                                 :method  "tools/call"
                                 :params  {:name      "orcha-master-data-legal-entities"
                                           :arguments {"search" (subs le-name 0 10)}}
                                 :id      1}
                                token)
          ;; Search with something that doesn't match
          no-match (mcp-request {:jsonrpc "2.0"
                                 :method  "tools/call"
                                 :params  {:name      "orcha-master-data-legal-entities"
                                           :arguments {"search" "zzz-nonexistent-zzz"}}
                                 :id      2}
                                token)]
      (let [result1 (json/parse-string (get-in match [:body :result :content 0 :text]) true)
            result2 (json/parse-string (get-in no-match [:body :result :content 0 :text]) true)]
        (is (= 1 (count (:legal_entities result1))))
        (is (= 0 (count (:legal_entities result2))))))))

Step 2: Run test to verify it fails

Run: clj -X:test:silent :nses '[com.getorcha.link.mcp-test]' :vars '[test-tool-master-data-legal-entities test-tool-master-data-legal-entities-search]' Expected: FAIL — tool not found

Step 3: Write the implementation

Create src/com/getorcha/link/mcp/tools/master_data.clj:

(ns com.getorcha.link.mcp.tools.master-data
  "MCP tools for querying master data.

  - orcha-master-data-legal-entities: Lists legal entities accessible to the identity
  - orcha-data-master-data: Queries GL accounts, cost centers, or business partners"
  (:require [cheshire.core :as json]
            [com.getorcha.db.sql :as db.sql]
            [com.getorcha.link.mcp.tools :as tools]))


(defn ^:private handle-legal-entities
  "Handler for orcha-master-data-legal-entities tool."
  [args {:keys [db-pool legal-entity-ids] :as _context}]
  (let [search  (:search args)
        where   (cond-> [:in :id [:lift (vec legal-entity-ids)]]
                  search (vector :and [:like [:lower :name] (str "%" (clojure.string/lower-case search) "%")]))
        results (db.sql/execute! db-pool {:select   [:id :name]
                                          :from     [:legal-entity]
                                          :where    where
                                          :order-by [[:name :asc]]})]
    {:content [{:type "text"
                :text (json/generate-string
                       {:legal_entities (mapv (fn [{:legal-entity/keys [id name]}]
                                               {:id   (str id)
                                                :name name})
                                             results)})}]}))


(tools/register-tool!
 {:name        "orcha-master-data-legal-entities"
  :description "List legal entities (companies) accessible to your organization. Use this to discover available legal entity IDs before querying master data."
  :inputSchema {:type       "object"
                :properties {"search" {:type        "string"
                                       :description "Filter by name (case-insensitive partial match)"}}
                :required   []}
  :handler     handle-legal-entities
  :scope       "mcp:read"})

Note: Use [:like [:lower :name] ...] instead of ILIKE because HoneySQL doesn't have a built-in :ilike operator. The [:lower ...] + [:like ...] combination achieves the same result.

Add to init-tools! in src/com/getorcha/link/mcp/tools.clj:

(require 'com.getorcha.link.mcp.tools.master-data)

Step 4: Run test to verify it passes

Run: clj -X:test:silent :nses '[com.getorcha.link.mcp-test]' :vars '[test-tool-master-data-legal-entities test-tool-master-data-legal-entities-search]' Expected: PASS

Step 5: Commit

git add src/com/getorcha/link/mcp/tools/master_data.clj src/com/getorcha/link/mcp/tools.clj test/com/getorcha/link/mcp_test.clj
git commit -m "feat: add orcha-master-data-legal-entities MCP tool"

Files:

Step 1: Write the failing tests

Add to test/com/getorcha/link/mcp_test.clj:

;; Helper: insert GL accounts dataset
(defn ^:private create-test-gl-accounts!
  "Creates an active GL accounts dataset for a legal entity."
  [db legal-entity-id accounts]
  (db.sql/execute-one!
   db
   {:insert-into :gl-accounts-dataset
    :values      [{:legal-entity-id legal-entity-id
                   :data            [:cast (json/generate-string accounts) :jsonb]
                   :is-active       true}]}))


;; orcha-data-master-data Tool Tests
;; -----------------------------------------------------------------------------

(deftest test-tool-master-data-gl-accounts
  (testing "orcha-data-master-data returns GL accounts"
    (mcp.tools/init-tools!)
    (let [{:keys [identity legal-entity]} (create-test-identity-with-legal-entity! fixtures/*db*)
          le-id    (:legal-entity/id legal-entity)
          accounts [{:number "4200" :name "Erlöse" :balance-position "GuV"}
                    {:number "6300" :name "Büromaterial" :balance-position "GuV"}
                    {:number "1200" :name "Bank" :balance-position "Aktiva"}]
          _        (create-test-gl-accounts! fixtures/*db* le-id accounts)
          token    (get-test-access-token fixtures/*db* identity)
          response (mcp-request {:jsonrpc "2.0"
                                 :method  "tools/call"
                                 :params  {:name      "orcha-data-master-data"
                                           :arguments {"type" "gl-accounts"}}
                                 :id      1}
                                token)]
      (is (= 200 (:status response)))
      (let [content (get-in response [:body :result :content 0 :text])
            result  (json/parse-string content true)]
        (is (= "gl-accounts" (:type result)))
        (is (= (str le-id) (:legal_entity_id result)))
        (is (= 3 (:total_count result)))
        (is (= 3 (count (:items result))))))))


(deftest test-tool-master-data-gl-accounts-search
  (testing "orcha-data-master-data filters GL accounts by search term"
    (mcp.tools/init-tools!)
    (let [{:keys [identity legal-entity]} (create-test-identity-with-legal-entity! fixtures/*db*)
          le-id    (:legal-entity/id legal-entity)
          accounts [{:number "4200" :name "Erlöse" :balance-position "GuV"}
                    {:number "6300" :name "Büromaterial" :balance-position "GuV"}
                    {:number "1200" :name "Bank" :balance-position "Aktiva"}]
          _        (create-test-gl-accounts! fixtures/*db* le-id accounts)
          token    (get-test-access-token fixtures/*db* identity)
          response (mcp-request {:jsonrpc "2.0"
                                 :method  "tools/call"
                                 :params  {:name      "orcha-data-master-data"
                                           :arguments {"type"   "gl-accounts"
                                                       "search" "4200"}}
                                 :id      1}
                                token)]
      (is (= 200 (:status response)))
      (let [content (get-in response [:body :result :content 0 :text])
            result  (json/parse-string content true)]
        (is (= 1 (:total_count result)))
        (is (= "4200" (get-in result [:items 0 :number])))))))


(deftest test-tool-master-data-gl-accounts-pagination
  (testing "orcha-data-master-data paginates GL accounts"
    (mcp.tools/init-tools!)
    (let [{:keys [identity legal-entity]} (create-test-identity-with-legal-entity! fixtures/*db*)
          le-id    (:legal-entity/id legal-entity)
          accounts (mapv (fn [i] {:number (str (+ 1000 i)) :name (str "Account " i) :balance-position "GuV"})
                         (range 5))
          _        (create-test-gl-accounts! fixtures/*db* le-id accounts)
          token    (get-test-access-token fixtures/*db* identity)
          response (mcp-request {:jsonrpc "2.0"
                                 :method  "tools/call"
                                 :params  {:name      "orcha-data-master-data"
                                           :arguments {"type"     "gl-accounts"
                                                       "per_page" 2
                                                       "page"     2}}
                                 :id      1}
                                token)]
      (is (= 200 (:status response)))
      (let [content (get-in response [:body :result :content 0 :text])
            result  (json/parse-string content true)]
        (is (= 5 (:total_count result)))
        (is (= 2 (:per_page result)))
        (is (= 2 (:page result)))
        (is (= 2 (count (:items result))))))))


(deftest test-tool-master-data-auto-select-legal-entity
  (testing "orcha-data-master-data auto-selects when identity has one LE"
    (mcp.tools/init-tools!)
    (let [{:keys [identity legal-entity]} (create-test-identity-with-legal-entity! fixtures/*db*)
          le-id    (:legal-entity/id legal-entity)
          _        (create-test-gl-accounts! fixtures/*db* le-id [{:number "1000" :name "Test" :balance-position "GuV"}])
          token    (get-test-access-token fixtures/*db* identity)
          ;; Call without legal_entity_id — should auto-select
          response (mcp-request {:jsonrpc "2.0"
                                 :method  "tools/call"
                                 :params  {:name      "orcha-data-master-data"
                                           :arguments {"type" "gl-accounts"}}
                                 :id      1}
                                token)]
      (is (= 200 (:status response)))
      (let [content (get-in response [:body :result :content 0 :text])
            result  (json/parse-string content true)]
        (is (= (str le-id) (:legal_entity_id result)))
        (is (= 1 (:total_count result)))))))


(deftest test-tool-master-data-unauthorized-legal-entity
  (testing "orcha-data-master-data rejects unauthorized legal entity ID"
    (mcp.tools/init-tools!)
    (let [{:keys [identity]} (create-test-identity-with-legal-entity! fixtures/*db*)
          token    (get-test-access-token fixtures/*db* identity)
          response (mcp-request {:jsonrpc "2.0"
                                 :method  "tools/call"
                                 :params  {:name      "orcha-data-master-data"
                                           :arguments {"type"            "gl-accounts"
                                                       "legal_entity_id" "00000000-0000-0000-0000-000000000000"}}
                                 :id      1}
                                token)]
      (is (= 200 (:status response)))
      (let [result (get-in response [:body :result])]
        (is (:isError result))
        (is (str/includes? (get-in result [:content 0 :text]) "not authorized"))))))


(deftest test-tool-master-data-no-dataset
  (testing "orcha-data-master-data returns empty when no active dataset"
    (mcp.tools/init-tools!)
    (let [{:keys [identity]} (create-test-identity-with-legal-entity! fixtures/*db*)
          token    (get-test-access-token fixtures/*db* identity)
          response (mcp-request {:jsonrpc "2.0"
                                 :method  "tools/call"
                                 :params  {:name      "orcha-data-master-data"
                                           :arguments {"type" "gl-accounts"}}
                                 :id      1}
                                token)]
      (is (= 200 (:status response)))
      (let [content (get-in response [:body :result :content 0 :text])
            result  (json/parse-string content true)]
        (is (= 0 (:total_count result)))
        (is (empty? (:items result)))))))

Step 2: Run tests to verify they fail

Run: clj -X:test:silent :nses '[com.getorcha.link.mcp-test]' :vars '[test-tool-master-data-gl-accounts test-tool-master-data-gl-accounts-search test-tool-master-data-gl-accounts-pagination test-tool-master-data-auto-select-legal-entity test-tool-master-data-unauthorized-legal-entity test-tool-master-data-no-dataset]' Expected: FAIL — tool not found

Step 3: Write the implementation

Add to src/com/getorcha/link/mcp/tools/master_data.clj:

(defn ^:private resolve-legal-entity-id
  "Resolves the legal entity ID from args and context.

  - Provided + authorized → returns the UUID
  - Provided + not authorized → returns error map
  - Omitted + 1 LE in context → auto-selects
  - Omitted + multiple LEs → returns error map"
  [args {:keys [legal-entity-ids] :as _context}]
  (if-let [le-id-str (:legal_entity_id args)]
    (let [le-id (parse-uuid le-id-str)]
      (if (contains? (set legal-entity-ids) le-id)
        {:ok le-id}
        {:error "You are not authorized to access this legal entity."}))
    (if (= 1 (count legal-entity-ids))
      {:ok (first legal-entity-ids)}
      {:error "Multiple legal entities available. Use orcha-master-data-legal-entities to list them, then specify legal_entity_id."})))


(defn ^:private error-response
  "Returns an MCP error response."
  [message]
  {:isError true
   :content [{:type "text" :text message}]})


(def ^:private type-config
  "Configuration for each master data type.

  - :table — the dataset table
  - :active-where — condition for active datasets
  - :search-fields — JSONB keys to search (nil = search all string values)"
  {"gl-accounts"       {:table         :gl-accounts-dataset
                        :active-where  [:= :is-active true]
                        :search-fields ["number" "name"]}
   "cost-centers"      {:table         :cost-center-dataset
                        :active-where  [:is-not :position nil]
                        :search-fields nil}
   "business-partners" {:table         :business-partner-dataset
                        :active-where  [:= :is-active true]
                        :search-fields ["account-number" "name" "vat-id" "iban"]}})


(defn ^:private build-search-condition
  "Builds a WHERE clause for JSONB search.

  When search-fields is nil (cost centers), searches across all values
  in the JSONB object using jsonb_each_text."
  [search search-fields]
  (if search-fields
    (into [:or] (map (fn [field]
                       [:like
                        [:lower [:raw (str "elem->>'" field "'")]]
                        (str "%" (clojure.string/lower-case search) "%")])
                     search-fields))
    [:raw (str "EXISTS (SELECT 1 FROM jsonb_each_text(elem) kv "
               "WHERE lower(kv.value) LIKE '%" (clojure.string/lower-case search) "%')")]))


(defn ^:private query-master-data
  "Queries a master data dataset with JSONB unnesting, optional search, and pagination."
  [db-pool legal-entity-id {:keys [table active-where search-fields]} search page per-page]
  (let [;; Base: get the data column from the active dataset
        ;; We use a CTE to unnest the JSONB array, then filter/paginate
        search-clause (when search
                        (build-search-condition search search-fields))
        base-where    (cond-> [:and
                               [:= :legal-entity-id legal-entity-id]
                               active-where]
                        search-clause identity)
        ;; Count query: total matching rows
        count-sql     {:select [[[:raw "count(*)"] :total]]
                       :from   [[[:raw (str (name table) ", jsonb_array_elements(data) elem")]]]
                       :where  (if search-clause
                                 [:and base-where search-clause]
                                 base-where)}
        total         (or (:total (db.sql/execute-one! db-pool count-sql)) 0)
        ;; Data query: paginated results
        data-sql      {:select [[:raw "elem"]]
                       :from   [[[:raw (str (name table) ", jsonb_array_elements(data) elem")]]]
                       :where  (if search-clause
                                 [:and base-where search-clause]
                                 base-where)
                       :limit  per-page
                       :offset (* (dec page) per-page)}
        rows          (db.sql/execute! db-pool data-sql)
        ;; For cost centers, also fetch headers
        headers       (when (= table :cost-center-dataset)
                        (:cost-center-dataset/headers
                         (db.sql/execute-one! db-pool {:select [:headers]
                                                       :from   [table]
                                                       :where  [:and
                                                                 [:= :legal-entity-id legal-entity-id]
                                                                 active-where]})))]
    {:total   total
     :items   (mapv :elem rows)
     :headers headers}))


(defn ^:private handle-master-data
  "Handler for orcha-data-master-data tool."
  [args {:keys [db-pool] :as context}]
  (let [resolution (resolve-legal-entity-id args context)]
    (if-let [error (:error resolution)]
      (error-response error)
      (let [le-id    (:ok resolution)
            type-str (:type args)
            config   (get type-config type-str)
            page     (or (:page args) 1)
            per-page (min (or (:per_page args) 100) 500)
            search   (:search args)
            result   (query-master-data db-pool le-id config search page per-page)]
        {:content [{:type "text"
                    :text (json/generate-string
                           (cond-> {:type            type-str
                                    :legal_entity_id (str le-id)
                                    :total_count     (:total result)
                                    :page            page
                                    :per_page        per-page
                                    :items           (:items result)}
                             (:headers result) (assoc :headers (:headers result))))}]}))))


(tools/register-tool!
 {:name        "orcha-data-master-data"
  :description "Query master data for a legal entity. Returns GL accounts (chart of accounts), cost centers, or business partners (creditors/debtors). Use for cross-referencing financial data against known entities."
  :inputSchema {:type       "object"
                :properties {"legal_entity_id" {:type        "string"
                                                :description "Legal entity UUID. Omit to auto-select if you have access to exactly one legal entity."}
                             "type"            {:type        "string"
                                                :description "Type of master data to query"
                                                :enum        ["gl-accounts" "cost-centers" "business-partners"]}
                             "search"          {:type        "string"
                                                :description "Filter by name or number (case-insensitive partial match). For GL accounts: searches number and name. For cost centers: searches all fields. For business partners: searches account number, name, VAT ID, and IBAN."}
                             "page"            {:type        "integer"
                                                :description "Page number (1-indexed)"
                                                :minimum     1
                                                :default     1}
                             "per_page"        {:type        "integer"
                                                :description "Results per page (max 500)"
                                                :minimum     1
                                                :maximum     500
                                                :default     100}}
                :required   ["type"]}
  :handler     handle-master-data
  :scope       "mcp:read"})

Note on the build-search-condition for cost centers: since cost center data has flexible headers, we use jsonb_each_text to search across all string values in each element. The search value is sanitized by lowercasing, but the SQL injection risk from clojure.string/lower-case being interpolated into the raw SQL string needs to be addressed. Use parameterized queries or escape the search string. Check if db.sql/execute! supports parameterized raw SQL — if not, escape single quotes in the search string.

Step 4: Run tests to verify they pass

Run: clj -X:test:silent :nses '[com.getorcha.link.mcp-test]' :vars '[test-tool-master-data-gl-accounts test-tool-master-data-gl-accounts-search test-tool-master-data-gl-accounts-pagination test-tool-master-data-auto-select-legal-entity test-tool-master-data-unauthorized-legal-entity test-tool-master-data-no-dataset]' Expected: PASS

Step 5: Commit

git add src/com/getorcha/link/mcp/tools/master_data.clj test/com/getorcha/link/mcp_test.clj
git commit -m "feat: add orcha-data-master-data MCP tool with GL accounts support"

Task 3: Cost centers and business partners support + tests

Files:

This task adds tests for the remaining two data types. The implementation from Task 2 already handles them via type-config — we just need to verify they work.

Step 1: Write the tests

Add to test/com/getorcha/link/mcp_test.clj:

;; Helpers for cost centers and business partners

(defn ^:private create-test-cost-centers!
  "Creates an active cost center dataset for a legal entity."
  [db legal-entity-id cost-centers headers]
  (db.sql/execute-one!
   db
   {:insert-into :cost-center-dataset
    :values      [{:legal-entity-id legal-entity-id
                   :data            [:cast (json/generate-string cost-centers) :jsonb]
                   :headers         [:cast (json/generate-string headers) :jsonb]
                   :position        0}]}))


(defn ^:private create-test-business-partners!
  "Creates an active business partner dataset for a legal entity."
  [db legal-entity-id partners]
  (db.sql/execute-one!
   db
   {:insert-into :business-partner-dataset
    :values      [{:legal-entity-id legal-entity-id
                   :data            [:cast (json/generate-string partners) :jsonb]
                   :is-active       true}]}))


(deftest test-tool-master-data-cost-centers
  (testing "orcha-data-master-data returns cost centers with headers"
    (mcp.tools/init-tools!)
    (let [{:keys [identity legal-entity]} (create-test-identity-with-legal-entity! fixtures/*db*)
          le-id   (:legal-entity/id legal-entity)
          centers [{"Number" "100" "Name" "Sales" "Employee" "John Doe"}
                   {"Number" "200" "Name" "IT" "Employee" "Jane Smith"}]
          headers ["Number" "Name" "Employee"]
          _       (create-test-cost-centers! fixtures/*db* le-id centers headers)
          token   (get-test-access-token fixtures/*db* identity)
          response (mcp-request {:jsonrpc "2.0"
                                 :method  "tools/call"
                                 :params  {:name      "orcha-data-master-data"
                                           :arguments {"type" "cost-centers"}}
                                 :id      1}
                                token)]
      (is (= 200 (:status response)))
      (let [content (get-in response [:body :result :content 0 :text])
            result  (json/parse-string content true)]
        (is (= "cost-centers" (:type result)))
        (is (= 2 (:total_count result)))
        (is (= ["Number" "Name" "Employee"] (:headers result)))
        (is (= 2 (count (:items result))))))))


(deftest test-tool-master-data-cost-centers-search
  (testing "orcha-data-master-data searches across all cost center fields"
    (mcp.tools/init-tools!)
    (let [{:keys [identity legal-entity]} (create-test-identity-with-legal-entity! fixtures/*db*)
          le-id   (:legal-entity/id legal-entity)
          centers [{"Number" "100" "Name" "Sales" "Employee" "John Doe"}
                   {"Number" "200" "Name" "IT" "Employee" "Jane Smith"}]
          _       (create-test-cost-centers! fixtures/*db* le-id centers ["Number" "Name" "Employee"])
          token   (get-test-access-token fixtures/*db* identity)
          ;; Search by employee name
          response (mcp-request {:jsonrpc "2.0"
                                 :method  "tools/call"
                                 :params  {:name      "orcha-data-master-data"
                                           :arguments {"type"   "cost-centers"
                                                       "search" "Jane"}}
                                 :id      1}
                                token)]
      (is (= 200 (:status response)))
      (let [content (get-in response [:body :result :content 0 :text])
            result  (json/parse-string content true)]
        (is (= 1 (:total_count result)))
        (is (= "200" (get-in result [:items 0 (keyword "Number")])))))))


(deftest test-tool-master-data-business-partners
  (testing "orcha-data-master-data returns business partners"
    (mcp.tools/init-tools!)
    (let [{:keys [identity legal-entity]} (create-test-identity-with-legal-entity! fixtures/*db*)
          le-id    (:legal-entity/id legal-entity)
          partners [{:account-number "70001" :name "Acme GmbH" :vat-id "DE123456789" :iban "DE89370400440532013000"}
                    {:account-number "70002" :name "Beta AG" :vat-id "DE987654321" :iban "DE27100777770209299700"}]
          _        (create-test-business-partners! fixtures/*db* le-id partners)
          token    (get-test-access-token fixtures/*db* identity)
          response (mcp-request {:jsonrpc "2.0"
                                 :method  "tools/call"
                                 :params  {:name      "orcha-data-master-data"
                                           :arguments {"type" "business-partners"}}
                                 :id      1}
                                token)]
      (is (= 200 (:status response)))
      (let [content (get-in response [:body :result :content 0 :text])
            result  (json/parse-string content true)]
        (is (= "business-partners" (:type result)))
        (is (= 2 (:total_count result)))
        (is (nil? (:headers result)))))))


(deftest test-tool-master-data-business-partners-search
  (testing "orcha-data-master-data filters business partners by VAT ID"
    (mcp.tools/init-tools!)
    (let [{:keys [identity legal-entity]} (create-test-identity-with-legal-entity! fixtures/*db*)
          le-id    (:legal-entity/id legal-entity)
          partners [{:account-number "70001" :name "Acme GmbH" :vat-id "DE123456789" :iban "DE89370400440532013000"}
                    {:account-number "70002" :name "Beta AG" :vat-id "DE987654321" :iban "DE27100777770209299700"}]
          _        (create-test-business-partners! fixtures/*db* le-id partners)
          token    (get-test-access-token fixtures/*db* identity)
          response (mcp-request {:jsonrpc "2.0"
                                 :method  "tools/call"
                                 :params  {:name      "orcha-data-master-data"
                                           :arguments {"type"   "business-partners"
                                                       "search" "DE123"}}
                                 :id      1}
                                token)]
      (is (= 200 (:status response)))
      (let [content (get-in response [:body :result :content 0 :text])
            result  (json/parse-string content true)]
        (is (= 1 (:total_count result)))
        (is (= "Acme GmbH" (get-in result [:items 0 :name])))))))

Step 2: Run tests to verify they pass

Run: clj -X:test:silent :nses '[com.getorcha.link.mcp-test]' :vars '[test-tool-master-data-cost-centers test-tool-master-data-cost-centers-search test-tool-master-data-business-partners test-tool-master-data-business-partners-search]' Expected: PASS (implementation already supports these types)

If any fail, debug and fix the implementation.

Step 3: Commit

git add test/com/getorcha/link/mcp_test.clj
git commit -m "test: add cost center and business partner master data tests"

Task 4: Update tools/list test + lint

Files:

Step 1: Update the test-tools-list test

The existing test-tools-list asserts (<= 4 (count tools)) and checks for specific tool names. Update to include the new tools:

In test-tools-list, change:

Step 2: Run lint

Run: clj-kondo --lint src/com/getorcha/link/mcp/tools/master_data.clj test/com/getorcha/link/mcp_test.clj

Fix any issues.

Step 3: Run all MCP tests

Run: clj -X:test:silent :nses '[com.getorcha.link.mcp-test]' Expected: ALL PASS

Step 4: Commit

git add test/com/getorcha/link/mcp_test.clj src/com/getorcha/link/mcp/tools/master_data.clj
git commit -m "chore: update tools list test for master data tools + lint fixes"

Implementation Notes

SQL injection in build-search-condition: The raw SQL interpolation for cost center search (jsonb_each_text) is a potential injection vector. The implementer must either:

  1. Escape single quotes in the search string before interpolation (replace ' with '')
  2. Or find a way to parameterize the raw SQL through HoneySQL

This applies to all [:raw ...] SQL that includes user input. Check how existing code in accounts_payable.clj:768 handles this — it may already have a pattern.

HoneySQL ILIKE: Verify if HoneySQL supports :ilike directly. If it does, use it instead of the [:lower ...] + [:like ...] workaround. Check the HoneySQL source at /home/volrath/code/oss/honeysql/.

clojure.string require: The implementation uses clojure.string/lower-case — ensure it's in the :require vector of the namespace declaration.