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.
orcha-fpna-list-files Implementation PlanFor Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: MCP tool that lists files in a legal entity's FP&A data directory, with optional Excel summary extraction, backed by an abstract FileStore protocol.
Architecture: FileStore protocol defines list-files and read-file. A multimethod make-file-store dispatches on :protocol to construct backend-specific implementations. Only the local filesystem backend is implemented now. The MCP tool resolves legal entity → data source config → FileStore, then lists files and optionally extracts Excel metadata via Docjure.
Tech Stack: Clojure protocols, java.nio.file for local FS, Docjure (already in deps) for Excel metadata, HoneySQL for DB queries, MCP tool registration pattern.
Key references:
docs/plans/2026-03-04-fpna-list-files-design.mdsrc/com/getorcha/link/mcp/tools/docs/list.cljsrc/com/getorcha/link/mcp/tools.cljtest/com/getorcha/link/mcp_test.cljtest/com/getorcha/test/fixtures.cljfpna_data_source ColumnFiles:
resources/migrations/YYYYMMDDHHMMSS-add-fpna-data-source.up.sqlresources/migrations/YYYYMMDDHHMMSS-add-fpna-data-source.down.sqlStep 1: Create migration files
Generate the timestamp with bb migrate create add-fpna-data-source.
Up migration:
ALTER TABLE legal_entity ADD COLUMN fpna_data_source jsonb;
COMMENT ON COLUMN legal_entity.fpna_data_source IS
'FP&A data source config. JSON with "protocol" key and backend-specific fields. Example: {"protocol": "file", "path": "/data/acme/"}';
Down migration:
ALTER TABLE legal_entity DROP COLUMN fpna_data_source;
Step 2: Run migration
psql -h localhost -U postgres -d orcha -c "ALTER TABLE legal_entity ADD COLUMN fpna_data_source jsonb; COMMENT ON COLUMN legal_entity.fpna_data_source IS 'FP&A data source config. JSON with protocol key and backend-specific fields.';"
Verify: psql -h localhost -U postgres -d orcha -c "\d legal_entity" | grep fpna
Step 3: Commit
git add resources/migrations/*add-fpna-data-source*
git commit -m "db: add fpna_data_source JSONB column to legal_entity"
Files:
src/com/getorcha/link/mcp/file_store.cljStep 1: Write the protocol and multimethod
(ns com.getorcha.link.mcp.file-store
"FileStore protocol for abstract file system access.
Different storage backends (local filesystem, S3, Google Drive, SFTP)
implement this protocol. The `make-file-store` multimethod constructs
the appropriate implementation from a data source config map.")
(defprotocol FileStore
(list-files [store path opts]
"Lists files at path (relative to store root).
`path` is a relative path string (e.g., \"payroll/\" or \"\").
Empty string or nil means the root directory.
`opts` is a map:
- `:file-type` — filter by extension (e.g., \"xlsx\", \"csv\")
Returns a seq of maps:
- `:name` — file or directory name (e.g., \"Budget.xlsx\", \"payroll/\")
- `:path` — relative path from store root
- `:size` — file size in bytes (nil for directories)
- `:modified` — last modified as ISO-8601 string (nil for directories)
- `:type` — file extension without dot, or \"directory\"
- `:directory?` — boolean")
(read-file [store path]
"Returns a java.io.InputStream for the file at path.
`path` is relative to store root. Caller must close the stream.
Throws if file does not exist or path is invalid."))
(defmulti make-file-store
"Constructs a FileStore from a data source config map.
Dispatches on the `\"protocol\"` key. Each backend registers its own method.
Example configs:
- `{\"protocol\" \"file\", \"path\" \"/data/acme/\"}`
- `{\"protocol\" \"s3\", \"bucket\" \"data\", \"prefix\" \"acme/\"}`"
#(get % "protocol"))
Note: the config map uses string keys because it comes from JSONB (parsed by cheshire with string keys unless configured otherwise). Check how JSONB is read in the project — if as-kebab-maps converts keys, adjust accordingly. The fpna_data_source column is raw JSONB queried directly, so it will use whatever the row builder provides.
Actually — the pool uses as-kebab-maps which converts to keyword keys. But JSONB values inside a column get parsed by next-jdbc's JSONB reader. Check how structured-data (also JSONB) is handled: it uses string keys in test data but keyword keys after reading. The dispatch should use the keyword :protocol if JSONB is auto-parsed with keyword keys, or string "protocol" if parsed with string keys. Before implementing, verify by checking how structured-data JSONB is read — look at db.sql namespace for JSONB read/write handling. Adjust the multimethod dispatch function accordingly.
Step 2: Commit
git add src/com/getorcha/link/mcp/file_store.clj
git commit -m "feat: add FileStore protocol for abstract file system access"
Files:
src/com/getorcha/link/mcp/file_store/local.cljtest/com/getorcha/link/mcp/file_store/local_test.cljStep 1: Write tests for LocalFileStore
The test should create a temp directory structure and verify list-files and read-file behavior.
(ns com.getorcha.link.mcp.file-store.local-test
(:require [clojure.java.io :as io]
[clojure.test :refer [deftest is testing]]
[com.getorcha.link.mcp.file-store :as file-store]
[com.getorcha.link.mcp.file-store.local]))
(defn ^:private with-temp-dir
"Creates a temp directory, calls f with its path string, then cleans up."
[f]
(let [dir (java.nio.file.Files/createTempDirectory
"file-store-test"
(into-array java.nio.file.attribute.FileAttribute []))]
(try
(f (.toString dir))
(finally
;; Clean up recursively
(doseq [file (reverse (file-seq (io/file (.toString dir))))]
(.delete file))))))
(defn ^:private create-test-files!
"Creates test directory structure:
root/
Budget_2026.xlsx
Lohn_2025_01.xlsx
report.pdf
payroll/
January.xlsx
February.csv"
[root]
(let [payroll-dir (io/file root "payroll")]
(.mkdirs payroll-dir)
(spit (io/file root "Budget_2026.xlsx") "fake-xlsx-content")
(spit (io/file root "Lohn_2025_01.xlsx") "fake-xlsx-content")
(spit (io/file root "report.pdf") "fake-pdf-content")
(spit (io/file payroll-dir "January.xlsx") "fake-xlsx")
(spit (io/file payroll-dir "February.csv") "a,b,c\n1,2,3")))
(deftest test-list-files-root
(with-temp-dir
(fn [root]
(create-test-files! root)
(let [store (file-store/make-file-store {"protocol" "file" "path" root})
files (file-store/list-files store "" {})]
(testing "lists files and directories at root"
(is (= 4 (count files))) ;; 3 files + 1 directory
(let [names (set (map :name files))]
(is (contains? names "Budget_2026.xlsx"))
(is (contains? names "Lohn_2025_01.xlsx"))
(is (contains? names "report.pdf"))
(is (contains? names "payroll"))))
(testing "directories are marked correctly"
(let [dir-entry (first (filter :directory? files))]
(is (= "payroll" (:name dir-entry)))
(is (= "directory" (:type dir-entry)))))
(testing "files have size and modified"
(let [budget (first (filter #(= "Budget_2026.xlsx" (:name %)) files))]
(is (pos? (:size budget)))
(is (string? (:modified budget)))
(is (= "xlsx" (:type budget)))
(is (false? (:directory? budget)))))))))
(deftest test-list-files-subdirectory
(with-temp-dir
(fn [root]
(create-test-files! root)
(let [store (file-store/make-file-store {"protocol" "file" "path" root})
files (file-store/list-files store "payroll" {})]
(is (= 2 (count files)))
(is (= #{"January.xlsx" "February.csv"} (set (map :name files))))))))
(deftest test-list-files-filter-by-type
(with-temp-dir
(fn [root]
(create-test-files! root)
(let [store (file-store/make-file-store {"protocol" "file" "path" root})
files (file-store/list-files store "" {:file-type "xlsx"})]
(is (= 2 (count files)))
(is (every? #(= "xlsx" (:type %)) files))))))
(deftest test-list-files-nonexistent-directory
(with-temp-dir
(fn [root]
(let [store (file-store/make-file-store {"protocol" "file" "path" root})
files (file-store/list-files store "does-not-exist" {})]
(is (empty? files))))))
(deftest test-list-files-path-traversal-blocked
(with-temp-dir
(fn [root]
(let [store (file-store/make-file-store {"protocol" "file" "path" root})]
(is (thrown? Exception (file-store/list-files store "../etc" {})))
(is (thrown? Exception (file-store/list-files store "payroll/../../etc" {})))))))
(deftest test-read-file
(with-temp-dir
(fn [root]
(spit (io/file root "test.txt") "hello world")
(let [store (file-store/make-file-store {"protocol" "file" "path" root})]
(with-open [is (file-store/read-file store "test.txt")]
(is (= "hello world" (slurp is))))))))
(deftest test-read-file-path-traversal-blocked
(with-temp-dir
(fn [root]
(let [store (file-store/make-file-store {"protocol" "file" "path" root})]
(is (thrown? Exception (file-store/read-file store "../etc/passwd")))))))
Step 2: Run tests to verify they fail
clj -X:test:silent :nses '[com.getorcha.link.mcp.file-store.local-test]'
Expected: compilation failure (namespace doesn't exist).
Step 3: Implement LocalFileStore
(ns com.getorcha.link.mcp.file-store.local
"Local filesystem implementation of FileStore.
Reads files from a directory on the host machine. Used during
development and for on-premise deployments."
(:require [com.getorcha.link.mcp.file-store :as file-store])
(:import (java.io FileInputStream FileNotFoundException)
(java.nio.file Files Path Paths)
(java.time Instant ZoneOffset)
(java.time.format DateTimeFormatter)))
(defn ^:private resolve-safe-path
"Resolves a relative path against the root, guarding against traversal.
Throws if the resolved path escapes the root directory."
^Path [^Path root ^String relative-path]
(let [resolved (.normalize (.resolve root relative-path))]
(when-not (.startsWith resolved root)
(throw (ex-info "Path traversal detected"
{:root (str root)
:relative relative-path
:resolved (str resolved)})))
resolved))
(defn ^:private file-extension
"Returns the lowercase file extension without the dot, or nil."
[^String filename]
(let [dot-idx (.lastIndexOf filename ".")]
(when (pos? dot-idx)
(.toLowerCase (.substring filename (inc dot-idx))))))
(defn ^:private file-entry
"Builds a file entry map from a Path."
[^Path root ^Path path]
(let [file (.toFile path)
name (.getName file)
relative (str (.relativize root path))
directory (.isDirectory file)]
(cond-> {:name name
:path relative
:directory? directory}
directory (assoc :type "directory")
(not directory) (assoc :type (file-extension name)
:size (.length file)
:modified (-> (Files/getLastModifiedTime path (into-array java.nio.file.LinkOption []))
.toInstant
(.atOffset ZoneOffset/UTC)
(.format DateTimeFormatter/ISO_OFFSET_DATE_TIME))))))
(defmethod file-store/make-file-store "file"
[{:strs [path] :as _config}]
(let [root (.normalize (Paths/get path (into-array String [])))]
(when-not (Files/isDirectory root (into-array java.nio.file.LinkOption []))
(throw (ex-info "FileStore root is not a directory" {:path path})))
(reify file-store/FileStore
(list-files [_ relative-path opts]
(let [target (if (or (nil? relative-path) (= "" relative-path))
root
(resolve-safe-path root relative-path))]
(if (Files/isDirectory target (into-array java.nio.file.LinkOption []))
(let [entries (->> (.listFiles (.toFile target))
(map #(file-entry root (.toPath %)))
(sort-by :name))]
(if-let [ft (:file-type opts)]
(filter #(or (:directory? %) (= ft (:type %))) entries)
entries))
[])))
(read-file [_ relative-path]
(let [target (resolve-safe-path root relative-path)
file (.toFile target)]
(when-not (.isFile file)
(throw (FileNotFoundException. (str "File not found: " relative-path))))
(FileInputStream. file))))))
Important: The dispatch uses {:strs [path]} (string keys) because JSONB from the database may be parsed either way. Verify how JSONB columns are deserialized in this project before finalizing. If the JSONB reader produces keyword keys, change to {:keys [path]} and update the multimethod dispatch to use :protocol keyword.
To check: read src/com/getorcha/db/sql.clj and look for JSONB read/write handling (look for ReadableColumn, pgobject, or jsonb). The tests in Task 3 use string keys in the config map — adjust if needed after verification.
Step 4: Run tests
clj -X:test:silent :nses '[com.getorcha.link.mcp.file-store.local-test]'
Expected: all pass.
Step 5: Lint
clj-kondo --lint src/com/getorcha/link/mcp/file_store src/com/getorcha/link/mcp/file_store/local.clj test/com/getorcha/link/mcp/file_store/local_test.clj
Step 6: Commit
git add src/com/getorcha/link/mcp/file_store/local.clj test/com/getorcha/link/mcp/file_store/local_test.clj
git commit -m "feat: add LocalFileStore implementation of FileStore protocol"
Files:
src/com/getorcha/link/queries/documents.cljStep 1: Add query function
Add to src/com/getorcha/link/queries/documents.clj, after the legal-entity-ids-for-identity function:
(defn resolve-legal-entity
"Resolves a single legal entity ID from optional input + allowed set.
Returns {:legal-entity-id uuid} on success, {:error \"message\"} on failure.
Cases:
1. `legal-entity-id` provided and in `allowed-ids` → return it
2. `legal-entity-id` provided but not in `allowed-ids` → error
3. Not provided, exactly one in `allowed-ids` → auto-resolve
4. Not provided, multiple in `allowed-ids` → error (use orcha-docs-list to find LEs)
5. Not provided, none in `allowed-ids` → error"
[legal-entity-id allowed-ids]
(if legal-entity-id
(let [parsed (parse-uuid (str legal-entity-id))]
(if (and parsed (contains? allowed-ids parsed))
{:legal-entity-id parsed}
{:error (str "Legal entity " legal-entity-id " is not accessible.")}))
(case (count allowed-ids)
0 {:error "No legal entities accessible."}
1 {:legal-entity-id (first allowed-ids)}
{:error "Multiple legal entities accessible. Provide legal_entity_id parameter."})))
(defn get-legal-entity-data-source
"Fetches the fpna_data_source config for a legal entity.
Returns the JSONB value (as a map) or nil if not configured."
[db-pool legal-entity-id]
(:legal-entity/fpna-data-source
(db.sql/execute-one!
db-pool
{:select [:fpna-data-source]
:from [:legal-entity]
:where [:= :id legal-entity-id]})))
Step 2: Commit
git add src/com/getorcha/link/queries/documents.clj
git commit -m "feat: add legal entity resolution and data source queries"
Files:
src/com/getorcha/link/mcp/tools/fpna/excel_summary.cljtest/com/getorcha/link/mcp/tools/fpna/excel_summary_test.cljThis task extracts the Excel summary logic into its own namespace so it can be tested independently of the MCP tool.
Step 1: Write tests
(ns com.getorcha.link.mcp.tools.fpna.excel-summary-test
(:require [clojure.test :refer [deftest is testing]]
[com.getorcha.link.mcp.tools.fpna.excel-summary :as excel-summary])
(:import (java.io ByteArrayInputStream ByteArrayOutputStream)
(org.apache.poi.xssf.usermodel XSSFWorkbook)))
(defn ^:private create-test-xlsx
"Creates a minimal .xlsx in memory and returns an InputStream.
`sheets` is a map of sheet-name → vector of rows (each row is a vector of cell values).
Example: {\"Budget\" [[\"Month\" \"Amount\"] [\"Jan\" 100] [\"Feb\" 200]]}"
[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 (.setCellValue cell (str value)))))))))
(.write wb out)
(.close wb)
(ByteArrayInputStream. (.toByteArray out))))
(deftest test-extract-summary-single-sheet
(let [input (create-test-xlsx {"Budget" [["Month" "Revenue" "Cost"]
["Jan" 1000 500]
["Feb" 1200 600]]})
summary (excel-summary/extract-summary input)]
(testing "returns sheets with names, columns, and row counts"
(is (= 1 (count (:sheets summary))))
(let [sheet (first (:sheets summary))]
(is (= "Budget" (:name sheet)))
(is (= ["Month" "Revenue" "Cost"] (:columns sheet)))
(is (= 3 (:row_count sheet)))))))
(deftest test-extract-summary-multiple-sheets
(let [input (create-test-xlsx {"Revenue" [["Q1" "Q2" "Q3" "Q4"]
[100 200 300 400]]
"Costs" [["Category" "Amount"]
["Rent" 2000]
["Salary" 5000]
["Travel" 500]]})
summary (excel-summary/extract-summary input)]
(is (= 2 (count (:sheets summary))))
(is (= #{"Revenue" "Costs"} (set (map :name (:sheets summary)))))
(let [costs (first (filter #(= "Costs" (:name %)) (:sheets summary)))]
(is (= ["Category" "Amount"] (:columns costs)))
(is (= 4 (:row_count costs))))))
(deftest test-extract-summary-empty-sheet
(let [input (create-test-xlsx {"Empty" []})
summary (excel-summary/extract-summary input)]
(let [sheet (first (:sheets summary))]
(is (= "Empty" (:name sheet)))
(is (= [] (:columns sheet)))
(is (= 0 (:row_count sheet))))))
Step 2: Run tests to verify they fail
clj -X:test:silent :nses '[com.getorcha.link.mcp.tools.fpna.excel-summary-test]'
Step 3: Implement
(ns com.getorcha.link.mcp.tools.fpna.excel-summary
"Extracts lightweight metadata from Excel files.
Used by the list-files tool when `include_summary` is true.
Reads sheet names, first-row headers, and row counts without
loading all cell data."
(:require [dk.ative.docjure.spreadsheet :as xl]))
(defn extract-summary
"Extracts summary metadata from an Excel InputStream.
Returns:
{:sheets [{:name \"Sheet1\"
:columns [\"Col A\" \"Col B\" ...]
:row_count 42}
...]}
The InputStream is consumed but NOT closed — caller manages lifecycle."
[input-stream]
(let [wb (xl/load-workbook-from-stream input-stream)
sheets (for [sheet (xl/sheet-seq wb)]
(let [rows (xl/row-seq sheet)
row-count (count rows)
headers (when (pos? row-count)
(->> (first rows)
xl/cell-seq
(map xl/read-cell)
(mapv #(if (some? %) (str %) ""))))]
{:name (xl/sheet-name sheet)
:columns (or headers [])
:row_count row-count}))]
{:sheets (vec sheets)}))
Step 4: Run tests
clj -X:test:silent :nses '[com.getorcha.link.mcp.tools.fpna.excel-summary-test]'
Step 5: Lint
clj-kondo --lint src/com/getorcha/link/mcp/tools/fpna/excel_summary.clj test/com/getorcha/link/mcp/tools/fpna/excel_summary_test.clj
Step 6: Commit
git add src/com/getorcha/link/mcp/tools/fpna/excel_summary.clj test/com/getorcha/link/mcp/tools/fpna/excel_summary_test.clj
git commit -m "feat: add Excel summary extraction for FP&A file listing"
orcha-fpna-list-filesFiles:
src/com/getorcha/link/mcp/tools/fpna/list_files.cljsrc/com/getorcha/link/mcp/tools.clj (add require to init-tools!)Step 1: Implement the tool
(ns com.getorcha.link.mcp.tools.fpna.list-files
"MCP tool for listing files in a legal entity's FP&A data directory.
Lists files with metadata (name, size, modification date, type).
Supports filtering by file type and optional Excel summary extraction."
(:require [cheshire.core :as json]
[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.tools :as tools]
[com.getorcha.link.mcp.tools.fpna.excel-summary :as excel-summary]
[com.getorcha.link.queries.documents :as queries]))
(defn ^:private tool-error
"Returns an MCP error response."
[message]
{:isError true
:content [{:type "text" :text (json/generate-string {:error message})}]})
(defn ^:private excel-file?
"Returns true if the file entry is an Excel file."
[{:keys [type] :as _entry}]
(contains? #{"xlsx" "xls"} type))
(defn ^:private enrich-with-summary
"Adds Excel summary to a file entry. Returns entry unchanged on failure."
[store {:keys [path] :as entry}]
(try
(with-open [is (file-store/read-file store path)]
(assoc entry :summary (excel-summary/extract-summary is)))
(catch Exception e
(log/warn e "Failed to extract Excel summary for" path)
(assoc entry :summary {:error (str "Failed to read: " (.getMessage e))}))))
(defn ^:private format-entry
"Formats a file entry for JSON response (snake_case keys)."
[{:keys [name path size modified type directory? summary] :as _entry}]
(cond-> {:name name
:path path
:type type
:directory (boolean directory?)}
(not directory?) (assoc :size size :modified modified)
summary (assoc :summary summary)))
(defn ^:private handle-list-files
"Handler for orcha-fpna-list-files tool."
[args {:keys [db-pool legal-entity-ids] :as _context}]
(let [{:keys [legal-entity-id error]} (queries/resolve-legal-entity
(:legal_entity_id args)
legal-entity-ids)]
(if error
(tool-error error)
(let [data-source (queries/get-legal-entity-data-source db-pool legal-entity-id)]
(if-not data-source
(tool-error (str "No FP&A data source configured for legal entity " legal-entity-id
". Set the fpna_data_source column on the legal_entity table."))
(try
(let [store (file-store/make-file-store data-source)
relative-path (or (:path args) "")
opts (cond-> {}
(:file_type args) (assoc :file-type (:file_type args)))
entries (file-store/list-files store relative-path opts)
include-summary (:include_summary args)
entries (if include-summary
(mapv #(if (excel-file? %)
(enrich-with-summary store %)
%)
entries)
entries)]
{:content [{:type "text"
:text (json/generate-string
{:legal_entity_id (str legal-entity-id)
:path (if (= "" relative-path) "/" relative-path)
:files (mapv format-entry entries)})}]})
(catch Exception e
(log/error e "Failed to list files for legal entity" legal-entity-id)
(tool-error (str "Failed to list files: " (.getMessage e))))))))))
(tools/register-tool!
{:name "orcha-fpna-list-files"
:description "List files in a legal entity's FP&A data directory. Returns file names, sizes, modification dates, and types. Use `file_type` to filter (e.g., 'xlsx'). Use `path` to navigate subdirectories. Use `include_summary` to get Excel sheet names, column headers, and row counts for triage."
:inputSchema {:type "object"
:properties {"legal_entity_id" {:type "string"
:description "Legal entity UUID. Optional if identity has access to exactly one legal entity."}
"path" {:type "string"
:description "Relative subdirectory path to list (e.g., 'payroll/'). Lists root if omitted."
:default ""}
"file_type" {:type "string"
:description "Filter by file extension (e.g., 'xlsx', 'csv', 'pdf')."}
"include_summary" {:type "boolean"
:description "When true, includes sheet names, column headers, and row counts for Excel files. Slower — use for targeted triage, not full listings."
:default false}}
:required []}
:handler handle-list-files
:scope "mcp:read"})
Step 2: Register in init-tools!
In src/com/getorcha/link/mcp/tools.clj, add to init-tools!:
(require 'com.getorcha.link.mcp.tools.fpna.list-files)
After the existing require lines, before the log statement.
Step 3: Lint
clj-kondo --lint src/com/getorcha/link/mcp/tools/fpna/list_files.clj src/com/getorcha/link/mcp/tools.clj
Step 4: Commit
git add src/com/getorcha/link/mcp/tools/fpna/list_files.clj src/com/getorcha/link/mcp/tools.clj
git commit -m "feat: add orcha-fpna-list-files MCP tool"
Files:
test/com/getorcha/link/mcp_test.cljAdd integration tests after the existing tool tests. These tests need:
fpna_data_source setStep 1: Add helper to set data source on legal entity
Add to the test helpers section:
(defn ^:private set-fpna-data-source!
"Sets the fpna_data_source JSONB on a legal entity."
[db legal-entity-id data-source]
(db.sql/execute-one!
db
{:update :legal-entity
:set {:fpna-data-source [:cast (json/generate-string data-source) :jsonb]}
:where [:= :id legal-entity-id]}))
Step 2: Write integration tests
;; orcha-fpna-list-files Tool Tests
;; -----------------------------------------------------------------------------
(deftest test-fpna-list-files-no-data-source
(testing "returns error when no data source configured"
(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-fpna-list-files"
:arguments {}}
: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]) "No FP&A data source"))))))
(deftest test-fpna-list-files-basic
(testing "lists files in configured data directory"
(mcp.tools/init-tools!)
(let [test-dir (str (java.nio.file.Files/createTempDirectory
"mcp-fpna-test"
(into-array java.nio.file.attribute.FileAttribute [])))
_ (spit (str test-dir "/Budget_2026.xlsx") "fake")
_ (spit (str test-dir "/report.pdf") "fake")
{:keys [identity legal-entity]} (create-test-identity-with-legal-entity! fixtures/*db*)
_ (set-fpna-data-source! fixtures/*db*
(:legal-entity/id legal-entity)
{"protocol" "file" "path" test-dir})
token (get-test-access-token fixtures/*db* identity)
response (mcp-request {:jsonrpc "2.0"
:method "tools/call"
:params {:name "orcha-fpna-list-files"
: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 (= "/" (:path result)))
(is (= 2 (count (:files result))))
(let [names (set (map :name (:files result)))]
(is (contains? names "Budget_2026.xlsx"))
(is (contains? names "report.pdf"))))
;; Cleanup
(doseq [f (reverse (file-seq (clojure.java.io/file test-dir)))]
(.delete f)))))
(deftest test-fpna-list-files-filter-type
(testing "filters by file type"
(mcp.tools/init-tools!)
(let [test-dir (str (java.nio.file.Files/createTempDirectory
"mcp-fpna-filter-test"
(into-array java.nio.file.attribute.FileAttribute [])))
_ (spit (str test-dir "/Budget.xlsx") "fake")
_ (spit (str test-dir "/data.csv") "a,b")
_ (spit (str test-dir "/report.pdf") "fake")
{:keys [identity legal-entity]} (create-test-identity-with-legal-entity! fixtures/*db*)
_ (set-fpna-data-source! fixtures/*db*
(:legal-entity/id legal-entity)
{"protocol" "file" "path" test-dir})
token (get-test-access-token fixtures/*db* identity)
response (mcp-request {:jsonrpc "2.0"
:method "tools/call"
:params {:name "orcha-fpna-list-files"
:arguments {"file_type" "xlsx"}}
: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 (:files result))))
(is (= "Budget.xlsx" (get-in result [:files 0 :name]))))
(doseq [f (reverse (file-seq (clojure.java.io/file test-dir)))]
(.delete f)))))
Step 3: Add required imports to the test ns
The test file needs clojure.java.io in requires (check if already present). Also ensure com.getorcha.link.mcp.file-store.local is loaded (it's required transitively by the tool namespace, but verify).
Step 4: Run the integration tests
clj -X:test:silent :nses '[com.getorcha.link.mcp-test]'
This runs the full test suite including the new tests. It takes time because it starts containers.
Step 5: Lint
clj-kondo --lint test/com/getorcha/link/mcp_test.clj
Step 6: Commit
git add test/com/getorcha/link/mcp_test.clj
git commit -m "test: add integration tests for orcha-fpna-list-files"
Step 1: Run all tests
clj -X:test:silent 2>&1 | grep -A 5 -E "(FAIL in|ERROR in|Execution error|failed because|Ran .* tests)"
Verify no regressions in existing tests.
Step 2: Final lint check
clj-kondo --lint src test dev
Fix any issues.
Step 3: Commit any fixes
If lint or tests surface issues, fix and commit.