Connecting MassHunter Quant to an AI through its embedded scripts
MassHunter Quant runs most of my targeted LC-MS quantification work. It has no external API, no REST endpoint, no way to be driven from outside. But it does ship with embedded scripting. This is the note where I worked out how to turn that scripting into a bridge an AI agent can use. 🤖
Why connect an AI to Quant at all
In my daily Agilent LC-MS work, MassHunter Quantitative Analysis (Quant) is where the numbers happen. A batch holds samples, target compounds, integrated peaks, areas, heights, calibration curves. That is dense, structured, decision-ready data — and yet, as with most laboratory software, it is locked inside a GUI.
What I wanted: to let an AI agent ask real questions about a batch — "summarise this batch", "which peaks fell outside the calibration range?", "calculate the relative area and add the column" — and get grounded, verifiable answers. That sounds like a small thing, but the moment I looked at it, it raised a fundamental question: how do you talk to an application that deliberately has no door?
The interesting problem isn't the AI. It's the door. Most lab software has none.
Step 1 — The key constraint: Quant has no external API
Before any design, I mapped every plausible entry point into Quant. The result was decisive:
| Entry point | Exists? | Comment |
|---|---|---|
| REST / HTTP API | ❌ | No documented web API to drive Quant from outside |
| Public COM / .NET automation | ❌ (not documented) | The "Automation" MassHunter documents is data-acquisition sequence handling, not driving the Quant application from another process |
| Embedded IronPython scripting | ✅ | The only official way to code in Quant: Custom Action / Tools → Actions, a 2.7 IronPython engine injected with UIState + BatchDataSet |
| Files / export | ✅ | .batch.bin, result exports (CSV / tables) — readable outside the app; writing means reloading the batch |
Consequence: any bridge from an agent into Quant must route through a script running inside Quant — a relay that reads commands from outside and executes them against the official scripting API. Everything upstream of that (the MCP server, the transport) is standard code.
This mirrors a constraint I already hit elsewhere: for structural annotation (the Explorer → SIRIUS path) I rely on open formats and external engines. Here, there is no such luxury — the application owns its own data model.
Part 2 — The architecture: a file-based relay
The design has three worlds and one narrow bridge. I deliberately keep the protocol between them dead simple — files and JSON.
You (Hermes, an LLM agent)
│ MCP (JSON-RPC)
▼
"QuantBridge" (MCP server, Python — runs where Quant runs)
│ · exposes MCP tools (see below)
│ · writes a command → cmd_xxx.json in a watched folder
│ · notifies (new file = event)
▼
RELAY inside Quant (IronPython, Custom Action)
│ · polls the command folder
│ · executes on UIState / BatchDataSet (read, custom calc, show column…)
│ · writes the result back → result.json (+ a log)
▼
QuantBridge reads the result → returns it to Hermes
The heart of the bridge is the shared folder + the scripted relay — exactly the mechanism I validated on the relative-area project, generalised from a one-shot script into a command executor.
Why files instead of a TCP socket
My first instinct was a socket — it's more "real". But the embedded engine's constraints (which I'd already paid for in debugging) rule it out:
- The embedded IronPython 2.7 engine of a Custom Action is minimal: no standard library (
os,json,socketare absent), pure ASCII, zero cross-thread UI calls (any MessageBox / WPF call from the background thread crashes the host), and globals are injected only into the executed script. import socketwould be an import of a library that isn't there →No module named socket, exactly likeos/json.- A long-lived socket loop inside an
AsyncScriptrisks blocking the background thread and unstable. - A file-polling loop (re-read a folder) has no network dependency:
System.IO.Directory.GetFilesexists, and the whole thing is written in pure .NET.
The command file + polling pattern is the most compatible with the embedded engine. That is the path chosen.
Part 3 — The tools the bridge can expose
Using the scripting primitives already validated on the relative-area project, the relay can expose a useful toolset:
| MCP tool | What it does | Scripting primitive behind it |
|---|---|---|
ping |
Round-trip check of the app↔bridge link | relay writes {status: ok} |
batch_summary |
Summary of the open batch (samples, compounds, peaks) | BatchDataSet.PeakTable.Select(""), counting, BatchTable |
get_peaks(filter) |
List peaks with area, height, RT, compounds… | BatchDataSet.PeakTable.Select(<SQL-like>) + row["Col"] |
get_columns |
Columns actually available (to validate the API on your version) | GetColumnNames() (discovered during the project) |
set_peak_custom(metric) |
Custom calculation (e.g. relative area %) → a custom column | QuantSetPeakAttribute(...) + UIState.WorktablePane.ShowColumn(...) |
reload_run |
Re-run a calculation / re-walk the batch | re-triggers the relay after re-integration |
open_batch(path) |
Load a .batch.bin — to validate |
likely needs a UI command — grey zone |
reintegrate |
Re-integration | whether the scripting exposes a re-integration command — to check |
The honest limits to validate on the software (the RAG can't answer these): opening a batch remotely, re-integration, and automatic recalc after a sample change (the SDK model freezes values at execution time). Those are the three "uncertain" candidates on the roadmap.
Part 4 — The constraints I'm carrying (learned the hard way)
These aren't hypothetical — every one cost me a debug session:
- ASCII only. Without an encoding declaration, the engine parses the file as ASCII (PEP 263). Any non-ASCII byte anywhere →
SyntaxErrorExceptionat compile → no code runs, and the WPF host can crash with no message. Fix:# -*- coding: utf-8 -*-on line 1 and ASCII-pure content. - No standard library. Only builtins + .NET. JSON is emitted by a small hand-written writer (
System.IO.File.WriteAllText), sincejsondoesn't exist in the engine. - Zero UI calls cross-thread. A Custom Action runs as an
AsyncScript(background thread); anyMessageBox/ShowMessage→ uncatchable exception → host crash after the work is done. The log file is the only output. - No
if __name__ == "__main__"guard. The engine executes the file with a__name__that isn't"__main__", so a guarded block never runs — silent no-op. Execute at module level instead (the SDK's own pattern). - Globals injected only into the executed file. If a relay imports another module, that module has no
UIState/BatchDataSet. So I refactor logic intocompute(ui_state, batch_ds)and pass context explicitly. - Only a fixed set of custom columns can be written via
QuantSetPeakAttribute(UserCustomCalculation,UserCustomCalculation1-4,PromoteHit). And there is no column-rename API — the "Custom Calc." label is a localised MassHunter resource. - Values freeze at execution. The SDK pattern computes the whole batch in one pass; re-running the script after a re-integration is mandatory. No automatic recalculation.
The pragmatic verdict
This is not a "push a button and it lives forever" integration. It's a log-first, polling, explicit-recompute design — robust because it expects the engine's limitations rather than fighting them.
Part 5 — Roadmap, in phases
I'm not trying to build everything at once. Each phase is independently testable, and the foundation is the relative-area script already running in production.
- Phase 0 — Command-executor POC. Turn the relative-area script into a relay that reads a
cmd.jsonfrom a watched folder, executes the requested action, and writesresult.json. Test manually via Tools → Actions to confirm the file↔relay round-trip (a ping/echo). - Phase 1 — Minimal MCP server. A small Python (FastMCP) server on the Quant machine exposing
batch_summary,list_peaks,run_custom_calc,set_show_column,ping. It writes the command, polls the result, returns to the caller. Tested locally on the CLI. - Phase 2 — Wiring to Hermes. Register the server in Hermes (transport SSE/HTTP — Hermes is the MCP client; or stdio if it runs on the same machine). Then test real requests: "summarise the batch", "compute relative area and show the column".
- Phase 3 — Maturity. A continuous polling mode in Quant (a
while Trueloop +System.Threadingif it proves stable), theopen_batchcommand, an attempt at re-integration/recalc, and structured error handling (failure → an error file withcompleted: false, mirroring the relative-area log).
Honesty on "real-time": with the SDK model, values are pre-computed and frozen in one pass. A truly continuous mode will need re-running the calculation after every modification. The relay's long-poll stability is the most speculative point — it's exactly what Phase 3 tests.
What I have in my favour
- A validated foundation. The relative-area script already runs in production and nailed the hard parts: JSON logging from a sandboxed engine, pure-ASCII builds, the
compute(ui_state, batch_ds)pattern. - A strict log contract. Every run writes a timestamped log plus a "last run" copy with a
completedflag — so even a hard crash of the process leaves a trace. That single habit turned an opaque engine into something debuggable at a distance. - Test tooling on Linux. The scripts are validated without Windows using CLR stubs plus a static safety checker (
check-embedded-safety.py) that flags the five fatal mistakes before anything ships.
What I'd like to work on next
- Build and test the Phase 0 relay as a real command executor (not just the relative-area one-shot).
- Stand up the Phase 1 MCP server and prove the file↔relay round-trip end to end.
- Scope a Phase 2 agent request on a real batch and measure how the answers land.
- Stress the long-poll stability (Phase 3) — the point most likely to fail.
This sits next to the broader exploration of connecting Agilent mass-spec data to an AI — same drive, different tool: Quant is a quantification powerhouse with no API, so the bridge must be built inside it. It also builds on the same self-hosted agent and memory layer I run at home, and on the targeted RAG I use to search my own technical docs when the API questions get too specific.
The goal is not to replace the analyst. It's to give an agent the same hands the interface has — so the numbers I already produce become something I can interrogate, compare, and act on programmatically. That's a bridge worth building carefully.