Note (2026-04-24): After this document was written,
legal_entitywas renamed totenantand the oldtenantwas renamed toorganization. Read references to these terms with the pre-rename meaning.
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Build an MCP tool that executes sandboxed Clojure code (via SCI) against Excel files, enabling the DDP agent to programmatically inspect financial spreadsheets.
Architecture: V1 runs SCI in-process on the MCP server JVM. The execution boundary is evaluate-excel(code, file-bytes) → edn-string. POI loads the workbook from bytes, custom functions close over it, SCI evaluates the agent's code with a 30s thread deadline, and the result is serialized to EDN via pr-str.
Tech Stack: SCI (babashka/sci), Apache POI (ss.usermodel), existing MCP tool infrastructure.
Design doc: docs/plans/2026-03-05-fpna-excel-tool-design.md
Files:
deps.ednStep 1: Add SCI to deps.edn
Add to the :deps map:
org.babashka/sci {:mvn/version "0.8.44"}
Place it alphabetically among the existing deps.
Step 2: Verify it resolves
Run: clj -Stree | grep sci
Expected: SCI and its transitive deps appear in the tree.
Step 3: Commit
git add deps.edn
git commit -m "deps: add babashka/sci for sandboxed Clojure evaluation"
sheets and summaryThese are the host-side functions that SCI code will call. They operate on a POI Workbook and return plain Clojure data structures.
Files:
src/com/getorcha/link/mcp/tools/fpna/excel/functions.cljtest/com/getorcha/link/mcp/tools/fpna/excel/functions_test.cljStep 1: Write tests for sheets and summary
Use the create-test-xlsx helper pattern from list_files_test.clj — create
in-memory POI workbooks with known data.
(ns com.getorcha.link.mcp.tools.fpna.excel.functions-test
(:require [clojure.test :refer [deftest is testing]]
[com.getorcha.link.mcp.tools.fpna.excel.functions :as excel.fn])
(:import (java.io ByteArrayInputStream ByteArrayOutputStream)
(org.apache.poi.xssf.usermodel XSSFWorkbook)))
(defn ^:private create-test-workbook
"Creates a POI Workbook in memory from a map of sheet-name -> rows.
Each row is a vector of cell values (strings, numbers, or nil).
Example: {\"Budget\" [[\"Month\" \"Amount\"] [\"Jan\" 100]]}"
[sheets]
(let [wb (XSSFWorkbook.)
out (ByteArrayOutputStream.)]
(doseq [[sheet-name rows] sheets]
(let [sheet (.createSheet wb sheet-name)]
(doseq [[row-idx row-data] (map-indexed vector rows)]
(let [row (.createRow sheet row-idx)]
(doseq [[col-idx value] (map-indexed vector row-data)]
(let [cell (.createCell row col-idx)]
(cond
(number? value) (.setCellValue cell (double value))
(string? value) (.setCellValue cell ^String value)
(nil? value) nil
:else (.setCellValue cell (str value)))))))))
(.write wb out)
(.close wb)
out))
(defn ^:private workbook-from
"Creates a POI Workbook from the output of create-test-workbook."
[^ByteArrayOutputStream out]
(XSSFWorkbook. (ByteArrayInputStream. (.toByteArray out))))
(deftest test-sheets
(let [wb (workbook-from (create-test-workbook {"Revenue" [["Q1"]]
"Costs" [["Cat"]]
"Summary" []}))]
(try
(is (= ["Revenue" "Costs" "Summary"] (excel.fn/sheets wb)))
(finally (.close wb)))))
(deftest test-summary
(let [wb (workbook-from (create-test-workbook
{"Budget" [["Month" "Revenue" "Cost"]
["Jan" 1000 500]
["Feb" 1200 600]]
"Empty" []}))]
(try
(let [result (excel.fn/summary wb)]
(testing "returns metadata per sheet"
(is (= #{"Budget" "Empty"} (set (keys result)))))
(testing "Budget sheet has headers, row count, column count"
(let [budget (get result "Budget")]
(is (= ["Month" "Revenue" "Cost"] (:headers budget)))
(is (= 3 (:row-count budget)))
(is (= 3 (:column-count budget)))))
(testing "Empty sheet has zero rows"
(let [empty-sheet (get result "Empty")]
(is (= [] (:headers empty-sheet)))
(is (= 0 (:row-count empty-sheet)))
(is (= 0 (:column-count empty-sheet))))))
(finally (.close wb)))))
Step 2: Run tests to verify they fail
Run: clj -X:test:silent :nses '[com.getorcha.link.mcp.tools.fpna.excel.functions-test]'
Expected: FAIL — namespace does not exist.
Step 3: Implement sheets and summary
(ns com.getorcha.link.mcp.tools.fpna.excel.functions
"Host-side Excel functions exposed to the SCI sandbox.
Each function takes a POI Workbook as its first argument. The SCI
sandbox binds these with the workbook already applied (via partial),
so SCI code calls them with no workbook argument."
(:import (org.apache.poi.ss.usermodel Cell CellType Row Sheet Workbook)))
(defn sheets
"Returns a vector of sheet names in workbook order."
[^Workbook workbook]
(mapv #(.getSheetName workbook %) (range (.getNumberOfSheets workbook))))
(defn ^:private read-cell-value
"Reads the display value of a cell, evaluating formulas."
[^Cell cell]
(when cell
(case (.name (.getCellType cell))
"NUMERIC" (let [v (.getNumericCellValue cell)]
(if (== v (long v)) (long v) v))
"STRING" (.getStringCellValue cell)
"BOOLEAN" (.getBooleanCellValue cell)
"FORMULA" (let [cached (.getCachedFormulaResultType cell)]
(case (.name cached)
"NUMERIC" (let [v (.getNumericCellValue cell)]
(if (== v (long v)) (long v) v))
"STRING" (.getRichStringCellValue cell)
"BOOLEAN" (.getBooleanCellValue cell)
nil))
"BLANK" nil
"ERROR" nil
nil)))
(defn ^:private sheet-headers
"Returns the values of the first row as a vector of strings."
[^Sheet sheet]
(if-let [row (.getRow sheet 0)]
(let [last-col (.getLastCellNum row)]
(if (pos? last-col)
(mapv (fn [i]
(let [cell (.getCell row (int i))]
(if (some? cell) (str (read-cell-value cell)) "")))
(range last-col))
[]))
[]))
(defn summary
"Returns a map from sheet names to {:headers [...] :row-count N :column-count N}."
[^Workbook workbook]
(into {}
(for [i (range (.getNumberOfSheets workbook))]
(let [sheet (.getSheetAt workbook i)
last-row (.getLastRowNum sheet)
row-count (if (and (zero? last-row)
(nil? (.getRow sheet 0)))
0
(inc last-row))
headers (sheet-headers sheet)
col-count (if (pos? row-count)
(let [row (.getRow sheet 0)]
(if row (int (.getLastCellNum row)) 0))
0)]
[(.getSheetName sheet)
{:headers headers
:row-count row-count
:column-count col-count}]))))
Step 4: Run tests to verify they pass
Run: clj -X:test:silent :nses '[com.getorcha.link.mcp.tools.fpna.excel.functions-test]'
Expected: PASS
Step 5: Commit
git add src/com/getorcha/link/mcp/tools/fpna/excel/functions.clj test/com/getorcha/link/mcp/tools/fpna/excel/functions_test.clj
git commit -m "feat: add excel/sheets and excel/summary POI functions"
read-rangeFiles:
src/com/getorcha/link/mcp/tools/fpna/excel/functions.cljtest/com/getorcha/link/mcp/tools/fpna/excel/functions_test.cljStep 1: Write tests for read-range
Add to the test file:
(deftest test-read-range-single-cell
(let [wb (workbook-from (create-test-workbook {"Sheet1" [["Hello" 42]
["World" 99]]}))]
(try
(is (= [[42]] (excel.fn/read-range wb "B1" {})))
(is (= [["Hello"]] (excel.fn/read-range wb "A1" {})))
(finally (.close wb)))))
(deftest test-read-range-area
(let [wb (workbook-from (create-test-workbook {"Sheet1" [["A" "B" "C"]
[1 2 3]
[4 5 6]]}))]
(try
(is (= [[1 2] [4 5]] (excel.fn/read-range wb "A2:B3" {})))
(finally (.close wb)))))
(deftest test-read-range-with-sheet-prefix
(let [wb (workbook-from (create-test-workbook {"Data" [["X" 10]
["Y" 20]]}))]
(try
(is (= [[10] [20]] (excel.fn/read-range wb "Data!B1:B2" {})))
(finally (.close wb)))))
(deftest test-read-range-metadata
(let [wb (XSSFWorkbook.)
out (ByteArrayOutputStream.)
sh (.createSheet wb "Sheet1")
row (.createRow sh 0)
c0 (.createCell row 0)
c1 (.createCell row 1)]
(.setCellValue c0 100.0)
(.setCellFormula c1 "A1*2")
;; Evaluate formulas so cached values exist
(.evaluateAll (.getCreationHelper wb))
(.write wb out)
(.close wb)
(let [wb2 (workbook-from out)]
(try
(let [result (excel.fn/read-range wb2 "A1:B1" {:metadata? true})]
(testing "plain cell has value and no formula"
(let [cell-a (get-in result [0 0])]
(is (= 100 (:value cell-a)))
(is (nil? (:formula cell-a)))))
(testing "formula cell has both value and formula string"
(let [cell-b (get-in result [0 1])]
(is (= 200 (:value cell-b)))
(is (= "A1*2" (:formula cell-b)))
(is (string? (:format cell-b))))))
(finally (.close wb2))))))
Step 2: Run tests to verify they fail
Run: clj -X:test:silent :nses '[com.getorcha.link.mcp.tools.fpna.excel.functions-test]'
Expected: FAIL — read-range not found.
Step 3: Implement read-range
Add to functions.clj:
(ns com.getorcha.link.mcp.tools.fpna.excel.functions
"..."
(:import (org.apache.poi.ss.usermodel Cell CellType DataFormatter Row Sheet Workbook)
(org.apache.poi.ss.util AreaReference CellReference)))
(defn ^:private cell-metadata
"Reads a cell as a metadata map: {:value v :formula s :format s}."
[^Cell cell]
(when cell
(let [formula (when (= CellType/FORMULA (.getCellType cell))
(.getCellFormula cell))
format (.getDataFormatString (.getCellStyle cell))]
{:value (read-cell-value cell)
:formula formula
:format format})))
(defn ^:private resolve-sheet-and-area
"Parses a range string into [Sheet, first-row, last-row, first-col, last-col].
Supports: \"A1\", \"A1:D10\", \"Sheet1!A1:D10\"."
[^Workbook workbook ^String range-str]
(let [version (.getSpreadsheetVersion workbook)
area-ref (AreaReference. range-str version)
first-ref (.getFirstCell area-ref)
last-ref (.getLastCell area-ref)
sheet-name (.getSheetName first-ref)
sheet (if sheet-name
(.getSheet workbook sheet-name)
(.getSheetAt workbook 0))]
(when-not sheet
(throw (ex-info (str "Sheet not found: " (or sheet-name "(default)"))
{:range range-str})))
[sheet
(.getRow first-ref) (.getRow last-ref)
(int (.getCol first-ref)) (int (.getCol last-ref))]))
(defn read-range
"Reads a cell range. Always returns a 2D vector.
`range-str` uses Excel notation: \"A1\", \"A1:D10\", \"Sheet1!A1:D10\".
`opts` may contain `:metadata? true` to return {:value :formula :format} maps."
[^Workbook workbook ^String range-str opts]
(let [[^Sheet sheet first-row last-row first-col last-col]
(resolve-sheet-and-area workbook range-str)
metadata? (:metadata? opts)]
(mapv (fn [row-idx]
(let [row (.getRow sheet row-idx)]
(mapv (fn [col-idx]
(let [cell (when row (.getCell row (int col-idx)))]
(if metadata?
(cell-metadata cell)
(read-cell-value cell))))
(range first-col (inc last-col)))))
(range first-row (inc last-row)))))
Step 4: Run tests to verify they pass
Run: clj -X:test:silent :nses '[com.getorcha.link.mcp.tools.fpna.excel.functions-test]'
Expected: PASS
Step 5: Commit
git add src/com/getorcha/link/mcp/tools/fpna/excel/functions.clj test/com/getorcha/link/mcp/tools/fpna/excel/functions_test.clj
git commit -m "feat: add excel/read-range with metadata support"
merged-regions and named-rangesFiles:
src/com/getorcha/link/mcp/tools/fpna/excel/functions.cljtest/com/getorcha/link/mcp/tools/fpna/excel/functions_test.cljStep 1: Write tests
(deftest test-merged-regions
(let [wb (XSSFWorkbook.)
out (ByteArrayOutputStream.)
sheet (.createSheet wb "Sheet1")]
;; Create a merged region B1:D1
(.addMergedRegion sheet (org.apache.poi.ss.util.CellRangeAddress. 0 0 1 3))
;; Create another A3:A5
(.addMergedRegion sheet (org.apache.poi.ss.util.CellRangeAddress. 2 4 0 0))
(.write wb out)
(.close wb)
(let [wb2 (workbook-from out)]
(try
(let [regions (excel.fn/merged-regions wb2 "Sheet1")]
(is (= 2 (count regions)))
(is (= #{"B1:D1" "A3:A5"} (set (map :range regions)))))
(finally (.close wb2))))))
(deftest test-merged-regions-empty
(let [wb (workbook-from (create-test-workbook {"Sheet1" [["A" "B"]]}))]
(try
(is (= [] (excel.fn/merged-regions wb "Sheet1")))
(finally (.close wb)))))
(deftest test-named-ranges
(let [wb (XSSFWorkbook.)
out (ByteArrayOutputStream.)]
(.createSheet wb "Sheet1")
(let [name1 (.createName wb)]
(.setNameName name1 "Revenue")
(.setRefersToFormula name1 "Sheet1!$A$1:$A$10"))
(let [name2 (.createName wb)]
(.setNameName name2 "Costs")
(.setRefersToFormula name2 "Sheet1!$B$1:$B$10")
(.setSheetIndex name2 0))
(.write wb out)
(.close wb)
(let [wb2 (workbook-from out)]
(try
(let [ranges (excel.fn/named-ranges wb2)]
(is (= 2 (count ranges)))
(let [revenue (first (filter #(= "Revenue" (:name %)) ranges))]
(is (= "Sheet1!$A$1:$A$10" (:refers-to revenue)))
(is (= :workbook (:scope revenue))))
(let [costs (first (filter #(= "Costs" (:name %)) ranges))]
(is (= "Sheet1" (:scope costs)))))
(finally (.close wb2))))))
(deftest test-named-ranges-filters-hidden
(let [wb (XSSFWorkbook.)
out (ByteArrayOutputStream.)]
(.createSheet wb "Sheet1")
;; Excel uses hidden names for things like print area, autofilter
(let [hidden-name (.createName wb)]
(.setNameName hidden-name "_xlnm.Print_Area")
(.setRefersToFormula hidden-name "Sheet1!$A$1:$D$20")
(.setHidden hidden-name true))
(let [visible-name (.createName wb)]
(.setNameName visible-name "Budget")
(.setRefersToFormula visible-name "Sheet1!$A$1:$A$5"))
(.write wb out)
(.close wb)
(let [wb2 (workbook-from out)]
(try
(let [ranges (excel.fn/named-ranges wb2)]
(is (= 1 (count ranges)))
(is (= "Budget" (:name (first ranges)))))
(finally (.close wb2))))))
Step 2: Run tests to verify they fail
Run: clj -X:test:silent :nses '[com.getorcha.link.mcp.tools.fpna.excel.functions-test]'
Expected: FAIL — merged-regions and named-ranges not found.
Step 3: Implement
Add to functions.clj:
(defn merged-regions
"Returns merged cell ranges for a sheet as [{:range \"B1:F1\"} ...]."
[^Workbook workbook ^String sheet-name]
(let [sheet (.getSheet workbook sheet-name)]
(when-not sheet
(throw (ex-info (str "Sheet not found: " sheet-name)
{:sheet-name sheet-name})))
(mapv (fn [region]
{:range (.formatAsString region)})
(.getMergedRegions sheet))))
(defn named-ranges
"Returns all non-hidden named ranges in the workbook.
Each entry: {:name s :refers-to s :scope (:workbook | sheet-name)}."
[^Workbook workbook]
(->> (.getAllNames workbook)
(remove #(.isHidden %))
(mapv (fn [named]
{:name (.getNameName named)
:refers-to (.getRefersToFormula named)
:scope (let [idx (.getSheetIndex named)]
(if (= -1 idx)
:workbook
(.getSheetName workbook idx)))}))))
Step 4: Run tests to verify they pass
Run: clj -X:test:silent :nses '[com.getorcha.link.mcp.tools.fpna.excel.functions-test]'
Expected: PASS
Step 5: Commit
git add src/com/getorcha/link/mcp/tools/fpna/excel/functions.clj test/com/getorcha/link/mcp/tools/fpna/excel/functions_test.clj
git commit -m "feat: add excel/merged-regions and excel/named-ranges"
evaluate-excelThe execution boundary function. Takes code string + file bytes, returns EDN string.
Files:
src/com/getorcha/link/mcp/tools/fpna/excel/sandbox.cljtest/com/getorcha/link/mcp/tools/fpna/excel/sandbox_test.cljStep 1: Write tests
(ns com.getorcha.link.mcp.tools.fpna.excel.sandbox-test
(:require [clojure.test :refer [deftest is testing]]
[clojure.tools.reader.edn :as edn]
[com.getorcha.link.mcp.tools.fpna.excel.sandbox :as sandbox])
(:import (java.io ByteArrayOutputStream)
(org.apache.poi.xssf.usermodel XSSFWorkbook)))
(defn ^:private test-xlsx-bytes
"Creates a minimal .xlsx as a byte array."
[sheets]
(let [wb (XSSFWorkbook.)
out (ByteArrayOutputStream.)]
(doseq [[sheet-name rows] sheets]
(let [sheet (.createSheet wb sheet-name)]
(doseq [[row-idx row-data] (map-indexed vector rows)]
(let [row (.createRow sheet row-idx)]
(doseq [[col-idx value] (map-indexed vector row-data)]
(let [cell (.createCell row col-idx)]
(cond
(number? value) (.setCellValue cell (double value))
(string? value) (.setCellValue cell ^String value)
:else nil)))))))
(.write wb out)
(.close wb)
(.toByteArray out)))
(def ^:private sample-bytes
(test-xlsx-bytes {"Budget" [["Month" "Revenue" "Cost"]
["Jan" 1000 500]
["Feb" 1200 600]]}))
(deftest test-evaluate-basic-expression
(let [result (edn/read-string (sandbox/evaluate-excel "(+ 1 2)" sample-bytes))]
(is (= 3 result))))
(deftest test-evaluate-sheets
(let [result (edn/read-string (sandbox/evaluate-excel "(excel/sheets)" sample-bytes))]
(is (= ["Budget"] result))))
(deftest test-evaluate-summary
(let [result (edn/read-string (sandbox/evaluate-excel "(excel/summary)" sample-bytes))]
(is (= 3 (get-in result ["Budget" :row-count])))))
(deftest test-evaluate-read
(let [result (edn/read-string (sandbox/evaluate-excel "(excel/read \"A1:C1\")" sample-bytes))]
(is (= [["Month" "Revenue" "Cost"]] result))))
(deftest test-evaluate-read-with-metadata
(let [result (edn/read-string
(sandbox/evaluate-excel
"(excel/read \"A1\" {:metadata? true})" sample-bytes))]
(is (map? (get-in result [0 0])))
(is (= "Month" (get-in result [0 0 :value])))))
(deftest test-evaluate-data-transformation
(let [code "(let [data (excel/read \"B2:C3\")]
(mapv (fn [row] (reduce + row)) data))"
result (edn/read-string (sandbox/evaluate-excel code sample-bytes))]
(is (= [1500 1800] result))))
(deftest test-evaluate-denied-symbols
(testing "loop is denied"
(let [result (edn/read-string
(sandbox/evaluate-excel "(loop [x 0] x)" sample-bytes))]
(is (:error result))))
(testing "eval is denied"
(let [result (edn/read-string
(sandbox/evaluate-excel "(eval '(+ 1 2))" sample-bytes))]
(is (:error result)))))
(deftest test-evaluate-no-java-interop
(let [result (edn/read-string
(sandbox/evaluate-excel "(System/exit 0)" sample-bytes))]
(is (:error result))))
(deftest test-evaluate-timeout
(let [result (edn/read-string
(sandbox/evaluate-excel
"(reduce + (range 999999999999))" sample-bytes))]
(is (= :timeout (get-in result [:error :type])))))
(deftest test-evaluate-parse-error
(let [result (edn/read-string
(sandbox/evaluate-excel "(+ 1" sample-bytes))]
(is (= :eval (get-in result [:error :type])))))
(deftest test-evaluate-bad-file
(let [result (edn/read-string
(sandbox/evaluate-excel "(excel/sheets)" (.getBytes "not an xlsx")))]
(is (= :parse (get-in result [:error :type])))))
Step 2: Run tests to verify they fail
Run: clj -X:test:silent :nses '[com.getorcha.link.mcp.tools.fpna.excel.sandbox-test]'
Expected: FAIL — namespace does not exist.
Step 3: Implement evaluate-excel
(ns com.getorcha.link.mcp.tools.fpna.excel.sandbox
"SCI sandbox for evaluating Clojure code against Excel workbooks.
The execution boundary: takes a code string and file bytes, returns
an EDN string. All Excel access goes through the custom `excel/*`
functions; the workbook object is never exposed to SCI code."
(:require [com.getorcha.link.mcp.tools.fpna.excel.functions :as excel.fn]
[sci.core :as sci])
(:import (java.io ByteArrayInputStream)
(org.apache.poi.ss.usermodel Workbook WorkbookFactory)))
(def ^:private timeout-ms 30000)
(def ^:private sci-deny
"Symbols denied in the sandbox."
'[loop recur trampoline eval load require import
resolve ns-resolve find-var intern alter-var-root
atom reset! swap! deref ref agent send send-off
future promise deliver pmap pcalls pvalues
read read-string read-line print println prn
spit slurp])
(defn ^:private build-sci-opts
"Builds SCI options with excel functions bound to the given workbook."
[^Workbook workbook]
(let [excel-ns {'sheets (fn [] (excel.fn/sheets workbook))
'summary (fn [] (excel.fn/summary workbook))
'read (fn
([range-str] (excel.fn/read-range workbook range-str {}))
([range-str opts] (excel.fn/read-range workbook range-str opts)))
'merged-regions (fn [sheet-name] (excel.fn/merged-regions workbook sheet-name))
'named-ranges (fn [] (excel.fn/named-ranges workbook))}]
{:deny sci-deny
:classes {'Math java.lang.Math}
:namespaces {'excel excel-ns
'clojure.string {'split clojure.string/split
'join clojure.string/join
'replace clojure.string/replace
'trim clojure.string/trim
'lower-case clojure.string/lower-case
'upper-case clojure.string/upper-case
'includes? clojure.string/includes?
'starts-with? clojure.string/starts-with?
'ends-with? clojure.string/ends-with?}}}))
(defn ^:private eval-with-timeout
"Evaluates SCI code with a thread deadline. Returns the result or an error map."
[code opts]
(let [result (promise)
thread (Thread.
(fn []
(try
(deliver result {:ok (sci/eval-string code opts)})
(catch Exception e
(deliver result {:error {:type :eval
:message (ex-message e)}})))))]
(.start thread)
(.join thread timeout-ms)
(if (realized? result)
@result
(do
(.interrupt thread)
{:error {:type :timeout}}))))
(defn evaluate-excel
"Execution boundary: evaluates Clojure code against an Excel file.
Arguments:
- `code` — Clojure code string
- `file-bytes` — raw Excel file bytes
Returns an EDN string: the serialized result or an error map."
[^String code ^bytes file-bytes]
(try
(let [workbook (WorkbookFactory/create (ByteArrayInputStream. file-bytes))]
(try
(let [opts (build-sci-opts workbook)
result (eval-with-timeout code opts)]
(pr-str (or (:ok result) result)))
(finally
(.close workbook))))
(catch Exception e
(pr-str {:error {:type :parse
:message (ex-message e)}}))))
Step 4: Run tests to verify they pass
Run: clj -X:test:silent :nses '[com.getorcha.link.mcp.tools.fpna.excel.sandbox-test]'
Expected: PASS (the timeout test will take ~30s — be patient).
Step 5: Commit
git add src/com/getorcha/link/mcp/tools/fpna/excel/sandbox.clj test/com/getorcha/link/mcp/tools/fpna/excel/sandbox_test.clj
git commit -m "feat: add SCI sandbox for Excel code evaluation"
Wires evaluate-excel into the MCP tool infrastructure following the pattern
from list-files.
Files:
src/com/getorcha/link/mcp/tools/fpna/excel.cljsrc/com/getorcha/link/mcp/tools/fpna.clj (add ::excel defmethod)Step 1: Implement the handler
(ns com.getorcha.link.mcp.tools.fpna.excel
"MCP handler for orcha-fpna-excel tool.
Resolves the legal entity, reads the file via FileStore, and passes
the code + bytes to the SCI sandbox."
(:require [clojure.tools.logging :as log]
[com.getorcha.link.mcp.file-store :as file-store]
[com.getorcha.link.mcp.file-store.local]
[com.getorcha.link.mcp.identity :as mcp.identity]
[com.getorcha.link.mcp.tools.fpna.excel.sandbox :as sandbox]
[com.getorcha.link.queries.documents :as queries]))
(defn handle-fpna-excel
"Handler for orcha-fpna-excel tool."
[args {:keys [db-pool legal-entity-ids] :as _context}]
(let [resolved (mcp.identity/resolve-legal-entity-from-args
(:legal_entity_id args) legal-entity-ids)]
(if (map? resolved)
resolved
(let [data-source (queries/get-legal-entity-data-source db-pool resolved)]
(if-not data-source
{:isError true
:content [{:type "text"
:text (pr-str {:error (str "No FP&A data source configured for legal entity "
resolved)})}]}
(try
(let [store (file-store/make-file-store data-source)
file-path (:file args)
code (:code args)
file-bytes (with-open [is (file-store/read-file store file-path)]
(.readAllBytes is))]
{:content [{:type "text"
:text (sandbox/evaluate-excel code file-bytes)}]})
(catch java.io.FileNotFoundException _
{:isError true
:content [{:type "text"
:text (pr-str {:error {:type :not-found
:message (str "File not found: " (:file args))}})}]})
(catch Exception e
(log/error e "orcha-fpna-excel failed for" resolved (:file args))
{:isError true
:content [{:type "text"
:text (pr-str {:error {:type :internal
:message (str "Tool execution failed: "
(.getMessage e))}})}]})))))))
Step 2: Add the defmethod in fpna.clj
Add to the require in com.getorcha.link.mcp.tools.fpna:
[com.getorcha.link.mcp.tools.fpna.excel :as fpna.excel]
Add the defmethod. The :description contains the full DSL reference — this is
intentionally long because it's what the agent reads to learn how to use the
tool:
(defmethod tools/-tool ::excel [_]
{:name "orcha-fpna-excel"
:description "Execute Clojure code to analyze an Excel file. The code runs in a sandboxed environment with these functions available:
**excel/sheets** `()` — Returns vector of sheet names.
`(excel/sheets)` => `[\"Sheet1\" \"Sheet2\"]`
**excel/summary** `()` — Returns map of sheet name to metadata (headers, row-count, column-count).
`(excel/summary)` => `{\"Sheet1\" {:headers [\"Month\" \"Revenue\"] :row-count 50 :column-count 5}}`
**excel/read** `(range)` or `(range opts)` — Reads cells. Range uses Excel notation: \"A1\", \"A1:D10\", \"Sheet1!A1:D10\". Always returns a 2D vector.
`(excel/read \"A1\")` => `[[42]]`
`(excel/read \"A1:C2\")` => `[[1 2 3] [4 5 6]]`
With `{:metadata? true}`, values become `{:value v :formula \"...\" :format \"...\"}`.
**excel/merged-regions** `(sheet-name)` — Returns merged cell ranges (no values).
`(excel/merged-regions \"Sheet1\")` => `[{:range \"B1:F1\"} {:range \"A3:A8\"}]`
**excel/named-ranges** `()` — Returns named ranges in the workbook.
`(excel/named-ranges)` => `[{:name \"Revenue\" :refers-to \"Sheet1!$B$2:$B$50\" :scope :workbook}]`
**Available Clojure core:** map, filter, reduce, mapv, filterv, into, get, get-in, assoc, dissoc, update, select-keys, keys, vals, merge, zipmap, group-by, sort-by, frequencies, first, second, last, rest, nth, take, drop, take-while, drop-while, concat, cons, conj, distinct, flatten, reverse, partition, partition-by, count, empty?, not-empty, contains?, some, every?, vector, hash-map, hash-set, set, list, vec, seq, range, repeat, str, subs, let, fn, def, defn, do, ->, ->>, cond->, some->, +, -, *, /, inc, dec, mod, max, min, abs, <, >, <=, >=, =, not=, and, or, not, if, when, cond, re-find, re-matches, Math/floor, Math/ceil, Math/round, Math/pow, clojure.string/split, clojure.string/join, clojure.string/replace, clojure.string/trim, clojure.string/lower-case, clojure.string/upper-case.
**Not available:** No IO, no Java interop, no loop/recur, no atoms. 30s timeout."
:inputSchema {:type "object"
:properties {"legal_entity_id" {:type "string"
:description "UUID of the legal entity. Optional if identity has access to exactly one."
:format "uuid"}
"file" {:type "string"
:description "Relative file path within the legal entity's data directory (e.g., 'Budget_2026.xlsx')."}
"code" {:type "string"
:description "Clojure code to evaluate against the Excel file."}}
:required ["file" "code"]}
:handler fpna.excel/handle-fpna-excel
:scope "fpna:read"})
Step 3: Run lint
Run: clj-kondo --lint src test dev
Expected: No new warnings.
Step 4: Commit
git add src/com/getorcha/link/mcp/tools/fpna/excel.clj src/com/getorcha/link/mcp/tools/fpna.clj
git commit -m "feat: register orcha-fpna-excel MCP tool"
Add operational guidance for the new tool to the DDP doc.
Files:
docs/controlling/data-discovery-protocol.mdStep 1: Update Phase 1 section
In Phase 1 (around line 36), after the line "use orcha-fpna-excel to:", update
the numbered list to reference the DSL functions:
Use `orcha-fpna-list-files` with `include_summary: true` to get sheet names and
headers for each file. Then, for files that need deeper analysis, use
`orcha-fpna-excel` to run Clojure code against the file:
1. Start with `(excel/summary)` to get sheet names, headers, row counts, and
column counts for all sheets
2. For each sheet:
- Read headers: `(excel/read "Sheet1!A1:Z1")`
- Read sample data: `(excel/read "Sheet1!A2:Z11")` (~10 rows)
- Check for merged cells: `(excel/merged-regions "Sheet1")` — if headers
appear empty or repeated, merged cells likely explain the structure
- Check for named ranges: `(excel/named-ranges)` — useful semantic markers
in hand-built financial models
3. Use `{:metadata? true}` with `excel/read` to detect formulas and number
formats (currency, date, percentage patterns in the `:format` field)
4. Describe what data the sheet contains — don't force it into predefined
categories, describe what you see
5. Record both:
- **Semantic markers**: header labels, sheet names, named ranges, and
structural cues that identify this data (these survive layout changes)
- **Cell references**: exact ranges where the data lives (precise for
programmatic access)
6. Classify the data shape:
- **Time series**: data spread across columns by period (e.g., Jan–Dec)
- **Transaction list**: one row per entry (e.g., bank transactions)
- **Snapshot**: a single point-in-time view (e.g., balance sheet)
- **Pivot table**: aggregated/grouped data
7. Note currency, language, and date formats (check `:format` in metadata)
Step 2: Update the Tool Workflow table
Add orcha-fpna-excel to the table around line 296:
| 1 | `orcha-fpna-excel` | Deep-dive into files: read cell ranges, detect formulas, merged cells, named ranges, number formats. |
Step 3: Commit
git add docs/controlling/data-discovery-protocol.md
git commit -m "docs: add orcha-fpna-excel operational guidance to DDP"
Step 1: Run linter
Run: clj-kondo --lint src test dev
Expected: No errors or warnings from new files.
Step 2: Run all tests
Run: clj -X:test:silent 2>&1 | grep -A 5 -E "(FAIL in|ERROR in|Execution error|failed because|Ran .* tests)"
Expected: All tests pass, including the new ones.
Step 3: Final commit (if any lint fixes needed)
Plan complete and saved to docs/plans/2026-03-05-fpna-excel-tool-impl.md. Two execution options:
1. Subagent-Driven (this session) — I dispatch a fresh subagent per task, review between tasks, fast iteration.
2. Parallel Session (separate) — Open new session with executing-plans, batch execution with checkpoints.
Which approach?