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.

orcha-fpna-excel V1 Implementation Plan

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


Task 1: Add SCI Dependency

Files:

Step 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"

Task 2: POI Excel Functions — sheets and summary

These are the host-side functions that SCI code will call. They operate on a POI Workbook and return plain Clojure data structures.

Files:

Step 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"

Task 3: POI Excel Functions — read-range

Files:

Step 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"

Task 4: POI Excel Functions — merged-regions and named-ranges

Files:

Step 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"

Task 5: SCI Sandbox — evaluate-excel

The execution boundary function. Takes code string + file bytes, returns EDN string.

Files:

Step 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"

Task 6: MCP Tool Handler

Wires evaluate-excel into the MCP tool infrastructure following the pattern from list-files.

Files:

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"

Task 7: Update Data Discovery Protocol

Add operational guidance for the new tool to the DDP doc.

Files:

Step 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"

Task 8: Lint and Full Test Run

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?