Jerem Flow

Connecting MassHunter Quant to an AI through its embedded scripts

· 9 mins read · #masshunter #quant #lc-ms #ironpython #mcp #ai #hermes #self-hosting #automation #agilent

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, socket are 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 socket would be an import of a library that isn't there → No module named socket, exactly like os/json.
  • A long-lived socket loop inside an AsyncScript risks blocking the background thread and unstable.
  • A file-polling loop (re-read a folder) has no network dependency: System.IO.Directory.GetFiles exists, 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.binto 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 → SyntaxErrorException at 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), since json doesn't exist in the engine.
  • Zero UI calls cross-thread. A Custom Action runs as an AsyncScript (background thread); any MessageBox/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 into compute(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.json from a watched folder, executes the requested action, and writes result.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 True loop + System.Threading if it proves stable), the open_batch command, an attempt at re-integration/recalc, and structured error handling (failure → an error file with completed: 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 completed flag — 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

  1. Build and test the Phase 0 relay as a real command executor (not just the relative-area one-shot).
  2. Stand up the Phase 1 MCP server and prove the file↔relay round-trip end to end.
  3. Scope a Phase 2 agent request on a real batch and measure how the answers land.
  4. 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.

← Back