A Model Context Protocol server that turns mass spectrometry into a first-class tool for agentic AI workflows — without ever stuffing spectral data into a context window.
MSMCP exposes exact-mass validation, adduct and isotope chemistry, spectral similarity scoring (classical and foundation-model embeddings), quality control, and asynchronous spectral library search to any LLM client that speaks the Model Context Protocol.
Large acquisitions never enter the conversation: spectra are parsed into server-side data references, so an agent can hand a 100,000-peak spectrum to the next tool as a short ptr:spectrum:… string, with structured provenance attached to every derived result.
- The problem: the Dark Metabolome meets the context window
- The solution: an MCP adapter with a data-reference store
- What you can ask it
- Server-side data references
- How async dispatch solves execution timeouts
- Architecture
- Try it in 60 seconds
- Connecting an LLM client
- An agent working through MSMCP
- Tool reference
- Instrument classes
- Supported input formats
- Glossary
- Provenance
- Known limitations & roadmap
- Security notes
- Configuration reference
For the full design — scope boundaries, layer rules, and the acceptance criteria (v1.0 and v1.1) — see ARCHITECTURE.md. For internals, testing, and the repository layout, see DEVELOPING.md.
Untargeted metabolomics is a data-volume problem wrapped around a chemistry problem. A single LC–MS/MS run produces thousands of spectra and easily more than 100,000 peaks; reference libraries contain hundreds of thousands of spectra. Even a 1M-token context window cannot hold one unfiltered run — and pasting raw peak lists into a prompt is the worst possible use of it: token burn, truncated spectra, and a standing invitation to hallucinate molecular identifications.
Worse, the chemistry itself is largely unknown. In untargeted studies, the overwhelming majority of detected features routinely lack confident structural annotation — the Dark Metabolome: the vast chemical space that instruments detect but reference databases do not yet name. Identifying anything in it demands:
- Exact-mass rigor — rejecting plausible-sounding but physically impossible proposals (ppm validation, isotope arithmetic);
- Similarity at scale — scanning huge libraries with classical peak matching and learned embeddings that generalise to spectra never seen before;
- Statistically honest scoring — FDR control rather than "looks similar" vibes.
All of that is deterministic, well-understood computation. LLMs should reason about it; they should not perform it.
MSMCP is a thin adapter between an LLM host and a stack of analytical engines:
- Transport layer — an MCPServer speaking JSON-RPC over stdio, launched as a child process by the LLM host. All diagnostics are logged to stderr; stdout carries only MCP framing.
- Ingestion layer (
src/msmcp/ingest.py) — one dispatcher that picks the right reader for a file, and one canonical output type. MassFlow is the canonical layer for everything it supports (imzML imaging data); MSMCP owns small readers for the formats MassFlow does not cover (mzML, MGF). - Analytical engines — pure, deterministic, unit-tested Python modules under
src/msmcp/tools/that perform the actual science: exact-mass arithmetic, adduct shifts, isotope annotation, ppm validation, cosine scoring, QC metrics, and chunked library scanning. - Data-reference store (
src/msmcp/state/) — a bounded, thread-safe registry of scientific payloads that stay on the server. This is what lets an agent hand a 100,000-peak spectrum to the next tool as a 30-character string. - Provenance (
src/msmcp/provenance.py) — immutable, structured records attached to every derived object: which operation, which parameters, which file (with reader backend and digest), which parent references, which software and model versions. - Model adapters — a pluggable
SpectralEmbedderinterface (src/msmcp/models/) behind which spectral foundation models (DreaMS, LSM-MS2) plug in without touching the tool layer. Optional: core MSMCP never requires model weights. - Execution (
src/msmcp/execution/) — long-running work goes through aJobExecutorinterface. v1.0 ships a local asyncio implementation; the server does not know or care whether the backend is a thread pool, a process pool or an orchestrator.
The server holds only bounded, TTL-limited state: registered data references and job records. The division of labour is explicit: the LLM plans, the tools compute, the store remembers.
MSMCP turns a conversation into an interface to your mass-spectrometry data. You ask in plain language and the agent picks the tools; the questions it is built for are these:
| Ask… | What happens | Tools it uses |
|---|---|---|
| "What's in this acquisition, and does it look healthy?" | Summarise a run, then score its quality | load_mzml_summary, generate_qc_summary |
| "Which MS2 spectra are in this run, and which should I search?" | An index of the run's spectra — index, MS level, retention time, precursor m/z, peak count — to choose a query | list_spectra |
| "Pull up the MS2 spectrum at index 12 and describe it." | Read one spectrum server-side and report its peaks | load_spectrum, summarise_reference |
| "I think this feature is caffeine — does the chemistry hold up?" | Check the adduct, isotope envelope and precursor mass error | predict_adduct_offset, annotate_isotopes, validate_precursor |
| "Is my measured precursor consistent with this formula?" | A ppm mass-error gate: pass or reject | validate_precursor |
| "How similar are these two spectra — and what is unexplained?" | Cosine similarity, plus the query peaks with no match | compute_cosine |
| "Search this spectrum against my spectral library." | A ranked, significance-scored hit table | search_library, check_search_status |
The point is not that the agent knows the chemistry. It is that every answer comes from deterministic, unit-tested computation, and every answer carries provenance you can trace back to the input file.
A DataReference is a compact, opaque handle to science that stays on the server:
ptr:spectrum:9f3c1a4e7b2d...
The flow is what keeps spectral data out of the context window:
load_spectrum(file_path="run42.mzML", spectrum_index=0)
-> {"reference": "ptr:spectrum:9f3c…", "n_peaks": 100000,
"mz_range": [50.0, 2000.0], "tic": 1.7e9,
"provenance": {"operation": "load_spectrum", …}}
search_library(spectrum_reference="ptr:spectrum:9f3c…",
database_file="libraries/metabolomics.db")
-> returns a job ID immediately; the peaks never left the server
release_reference(reference="ptr:spectrum:9f3c…")
-> freed now instead of waiting for the retention period
The properties that make it safe to rely on:
- Opaque and stable — generated from
secrets.token_hex. Never a Pythonid(), a memory address or a path. Stable for the reference's lifetime, never reused after release. - Metadata, never payload — a reference describes its data (kind, shape, byte size, lifetime, provenance). Its serialised form cannot contain the peaks.
- Immutable — registration validates the payload against its kind and stores it with read-only buffers, so a caller that mutates its own array cannot change what a later tool reads.
- Bounded —
MSMCP_MAX_REFERENCES(default 128) andMSMCP_MAX_TOTAL_BYTES(default 512 MiB) are enforced by refusing new registrations, never by evicting data someone still holds. A resource error must not become a wrong answer. - Expiring and releasable —
MSMCP_REFERENCE_TTL_SECONDS(default 3600;0disables) expires references lazily on access;release_referencefrees them immediately and is idempotent. - Namespaced — the store partitions references by namespace so one workflow cannot dereference another's data.
What is not claimed: MSMCP does not claim zero-copy. Registration takes one defensive copy to guarantee immutability, and PyArrow is not a dependency. The guarantee that is actually tested is that the payload is never serialised through MCP/JSON — which is the one that matters to an agent.
Migrating from the earlier “memory pointer” wording: the job ID returned by
search_libraryis an execution handle, not a data reference. Both remain: job IDs address work in flight,DataReferences address data at rest.
LLM hosts impose hard timeouts on tool calls — typically a minute or less. A library scan takes minutes. Naive synchronous tools therefore guarantee host-side timeouts.
MSMCP's dispatcher/poller split changes the failure model:
| Problem | The job executor's answer |
|---|---|
| Tool call exceeds the host timeout | submit returns in milliseconds; the scan continues on a worker thread via asyncio.to_thread |
| Server restarts mid-search | Job state is in-memory and is lost — the price of zero infrastructure (see limitations) |
| Too many concurrent searches | A bounded semaphore caps simultaneously running scans, so an eager agent cannot exhaust the CPU |
| Failure with no signal | Failed jobs carry the full exception traceback, retrievable by the poller |
| "Is it done yet?" | Status transitions (queued → running → completed/failed/cancelled) are queryable by job ID at any time |
Finished jobs are expired from the executor after a one-hour TTL, so a long-running server never accumulates unbounded state.
flowchart TD
Client["LLM Client (any MCP host)"]
subgraph MSMCP["MSMCP - MCPServer over stdio"]
Transport["Transport: JSON-RPC over stdio"]
ToolRouting["Tool routing: io · chem · similarity · qc · search"]
end
subgraph Ingest["Ingestion (ingest.py)"]
MassFlow["MassFlow: imzML imaging"]
Readers["MSMCP readers: mzML · MGF"]
end
subgraph State["Server-side state (bounded, TTL)"]
Refs["Data references: ptr:spectrum:..."]
Executor["JobExecutor: submit · status · cancel"]
Prov["Provenance records"]
end
subgraph Models["Spectral foundation models (optional)"]
Embedders["SpectralEmbedder: DreaMS · LSM-MS2"]
end
Client -->|MCP stdio| Transport
Transport --> ToolRouting
ToolRouting --> Ingest
ToolRouting --> Refs
ToolRouting --> Executor
ToolRouting --> Prov
ToolRouting -->|embed_spectrum| Embedders
Separation of concerns in one sentence: the transport marshals requests, the ingestion layer picks a reader, the tool modules implement the analytical engines, the reference store owns data at rest, the executor owns work in flight, and provenance records how each result was produced — no layer reaches into another's internals.
Prerequisites: uv and Python 3.13 (managed automatically via .python-version).
# 1. Clone and sync (creates the venv, installs runtime + dev dependencies)
git clone <repository-url> msmcp && cd msmcp
uv sync --extra dev
# 2. Launch the server on stdio, then connect a host (see the next section).
# The remaining commands are the same local gate CI runs.
uv run msmcp # start the MCP server (Ctrl-C to stop)
uv run ruff format --check . # CI runs this first; `make lint` does too
uv run ruff check .
uv run mypy
uv run basedpyright
uv run pytest # fast unit suite (the eval notebook is deselected)
# 3. Or run the whole pipeline — sync, format, lint, unit tests, eval notebook, repomix pack
make allThe repository ships a synthetic worked example, so you can see a real library search without sourcing any data of your own:
examples/caffeine_library.msp— a 5-compound MSP/NIST text library.examples/caffeine_spectrum.mgf— one MS2 spectrum whose peaks duplicate the caffeine record, so the top hit is deterministic.
These are fabricated demonstration spectra, not measured data: the example
exercises the search mechanics, not a chemical identification. (MGF libraries
are read too, through the same LibraryProvider interface.)
Connect your MCP host — Zed is already configured by this repository's
.zed/settings.json; for other hosts see Connecting an LLM
client — then ask the agent:
Load
examples/caffeine_spectrum.mgfand search it againstexamples/caffeine_library.mspwithinstrument_class="orbitrap".
You should get a REAL LIBRARY — READ FROM DISK banner with caffeine ranked first (the report also states the significance and the null model it used):
| Rank | Compound | Score | Precursor m/z | Formula |
|---|---|---|---|---|
| 1 | Caffeine | 1.0000 | 195.0877 | C8H10N4O2 |
uv is the canonical tool — every command runs through it:
| Task | Command |
|---|---|
| Sync environment | uv sync --extra dev |
| Format | uv run ruff format . |
| Lint | uv run ruff format --check . · uv run ruff check . · uv run mypy · uv run basedpyright |
| Test | uv run pytest |
| Evaluate end to end | uv run pytest tests/test_eval_notebook.py -m eval -s -q |
| Validate similarity (opt-in) | make validate-similarity (needs MSMCP_VALIDATION_DATA) |
| Run the server | uv run msmcp |
The Makefile wraps these into one pipeline: make lint, make test, make eval, and make all (sync → format → lint → unit tests → evaluation notebook → repomix pack). Every target depends on install, so make all works from a fresh clone without a separate sync step. make test deliberately omits the evaluation notebook — that is make eval — to keep the inner loop at a few seconds.
Re-run uv sync --extra dev after changing pyproject.toml. The optional repomix pack (Node-based) is run as repomix --style xml --output repomix-output.xml.
MSMCP keeps its scientific claims, its software tests and its identification confidence as separate things, and documents the evidence for each:
SCIENTIFIC_VALIDATION.md— per capability: the claim, the evidence, the tolerances and their justification, and an explicit VALIDATED / PARTIAL / NOT VALIDATED status. Isotopes and mzML ingestion are validated against independent references; similarity is PARTIAL; statistical significance/FDR is NOT VALIDATED.VALIDATION_SIMILARITY.md— one bounded experiment comparing MSMCP's scorer againstmatchmson a pinned MassBank release and measuring library recovery on held-out spectra, with its limitations stated plainly.
make validate-similarity runs that experiment opt-in: it needs the external dataset (MSMCP_VALIDATION_DATA) and supplies matchms for the one command, so neither the runtime nor the fast test suite depends on it. With no dataset it skips cleanly and downloads nothing.
MSMCP is a child-process MCP server. Any host that supports stdio MCP servers can spawn it with uv run msmcp from the project root.
Example configuration for Zed (.zed/settings.json):
{
"context_servers": {
"msmcp": {
"command": "uv",
"args": ["run", "msmcp"],
"env": {}
}
}
}command/args is the current Zed schema; the older nested command: {path, args} object is rewritten on load by Zed's 2025-11-25 settings migration.
User: "What's in run42/experiment.mzML? Then search it."
Agent: load_spectrum(file_path="run42/experiment.mzML", spectrum_index=0)
→ {"reference": "ptr:spectrum:9f3c…", "format": "mzML",
"n_peaks": 842, "mz_range": [50.0, 900.1], "tic": 4.1e7,
"provenance": {"operation": "load_spectrum", …}}
Agent: summarise_reference(reference="ptr:spectrum:9f3c…")
→ "Spectrum #0 | MS2 | RT: 2.00 min … Top 10 peaks …
Provenance: load_spectrum … source: run42/experiment.mzML"
Agent: search_library(spectrum_reference="ptr:spectrum:9f3c…",
database_file="libraries/metabolomics.library",
scoring_method="dreams")
→ "Job ID: 362c0267… (the peaks never entered the conversation)"
Agent: check_search_status(job_id="362c0267…")
→ "🔄 Running — scanning the spectral library …"
Agent: check_search_status(job_id="362c0267…")
→ "## Spectral Library Search Results …
| Rank | Compound | Score | FDR (q-value) | Precursor m/z | Formula | …"
Agent: validate_precursor(theoretical_mass=194.0804, experimental_mass=194.0831)
→ "VALIDATION REJECTED
Mass error: 13.91 ppm
Instrument class: generic (precursor tolerance 5.0 ppm)
… This is physically invalid for the hypothesised compound under the
generic class assumption. Reconsider the molecular formula, adduct
assignment, or instrument calibration."
Agent: release_reference(reference="ptr:spectrum:9f3c…")
→ "Released. The stored peaks are no longer available."
Agent: "The top hit is caffeine, but the precursor mass error (13.91 ppm)
fails validation — likely a sodiated adduct. Let me check…"
The agent carries references, job IDs and verdicts — never spectra.
Note on this walkthrough: the query spectrum is genuinely read from
experiment.mzML. The library depends on the path: an MSP/NIST-style text library (.msp,.msp.gz), an MGF library (.mgf,.mgf.gz), or a SQLite peak store (.sqlite,.db) is read from disk through the same security boundary as the query, while a path with no reader (such as themetabolomics.librarystring above) falls back to a synthetic library of 500–5,000 spectra seeded from that string. Either way the dispatch reply and the final report carry a banner naming which library actually answered the query, ahead of any hit table. Treat the hits as scoring results, never as identifications. See Known limitations & roadmap.
Fourteen tools are exposed to the model: compact Markdown (or a single sentence) for human/LLM reading, and a structured object where a data reference is involved.
| Tool | Module | Purpose |
|---|---|---|
ping |
tools/system.py |
Diagnostic health check |
load_mzml_summary |
tools/io.py |
First-N-spectra text summary of a local .mzML file |
list_spectra |
tools/io.py |
Index of a file's spectra — index, MS level, retention time, precursor m/z and peak count — for choosing one to load or search (ms_level/min_peaks filters, capped by max_spectra) |
load_spectrum |
tools/io.py |
Parse one spectrum from any supported format and register it server-side; returns a DataReference plus metadata and provenance |
summarise_reference |
tools/io.py |
Read a referenced spectrum back from server memory (no file re-read) |
release_reference |
tools/io.py |
Free a reference immediately rather than waiting for expiry |
predict_adduct_offset |
tools/chem.py |
Exact mass shift for 14 canonical adducts |
annotate_isotopes |
tools/chem.py |
Exact isotope envelope (M … M+n) from a formula or SMILES; isotopologues sets the depth, so a halogen-rich formula's M+4 is reachable |
validate_precursor |
tools/similarity.py |
Mass-error gate at the instrument class's precursor tolerance (5.0 ppm for the default generic class) |
compute_cosine |
tools/similarity.py |
Classical or real-embedding spectral similarity; each side may be inline peaks or a server-side reference (embeddings require real inference). The matching window comes from ms2_tolerance, or the instrument class when it is unset |
generate_qc_summary |
tools/qc.py |
Truthful QC metrics (TIC, spectrum counts, peak density, estimated SNR) from a real file, using the instrument class's diagnostic-ion window and SNR bands |
search_library |
tools/search.py |
Asynchronous library search (job executor; classical or real-embedding scoring). MSP/NIST text and MGF libraries are read from disk through the security boundary; unsupported formats use a labelled synthetic fallback. Significance uses a fitted decoy tail, but its q-floor can suppress hits at library scale; reports disclose the model and floor. The query is always real data. |
check_search_status |
tools/search.py |
Poll a dispatched search by job ID. The report is delivered once; later polls return a short digest, and full_report=True re-requests it |
cancel_search |
tools/search.py |
Cancel a pending or running search job |
Only cancel_search and release_reference mutate server state, and both advertise that on the wire.
Several tools apply an experimental threshold: validate_precursor's precursor
gate, compute_cosine's and search_library's m/z matching window,
generate_qc_summary's diagnostic-ion window and SNR bands, and
search_library's significance threshold and small-library floor. None of
these is a bare literal. Each is resolved from the instrument_class argument
(default generic), which names a documented profile in
src/msmcp/instruments.py:
| Class | Precursor gate | MS2 / diagnostic-ion window | SNR bands |
|---|---|---|---|
generic (default) |
5.0 ppm | 0.02 Da | 5 / 20 |
orbitrap |
5.0 ppm | 0.01 Da | 5 / 20 |
tof |
15.0 ppm | 0.05 Da | 5 / 20 |
ion_trap |
0.5 Da | 0.5 Da | 5 / 20 |
triple_quad |
0.7 Da | 0.7 Da | 5 / 20 |
Accurate-mass classes express the precursor gate in ppm; unit-resolution
classes (ion_trap, triple_quad) use a Da window, because a ppm gate shrinks
below one nominal mass at a few kDa and is meaningless on an instrument that
cannot resolve fractions of a nominal mass. The FDR threshold (0.05) and the
small-library floor (2000) are shared across classes — they are properties of
the statistics, not of the mass analyser.
The class and the values actually applied are always stated in the result: in
the report body for validate_precursor, compute_cosine and
generate_qc_summary, and in Provenance.parameters for search_library. An
explicit ms2_tolerance on compute_cosine overrides the class default, and
the response says which source the window came from. These are documented
conventions, not measured acquisition parameters — MSMCP does not yet parse
instrument model, resolution or calibration from a file (see Known
limitations & roadmap) — so the class is the assumption a result was produced
under, stated rather than hidden.
The reader is chosen from the file extension; every reader produces the same spectrum type, so no tool needs to know the format.
| Format | Reader | Notes |
|---|---|---|
.mzML, .mzML.gz |
msmcp.mzml |
dependency-free; zlib-compressed binary arrays supported |
.mgf, .mgf.gz |
msmcp.mgf |
strict: an unparseable peak line raises rather than being skipped |
.imzML (+ .ibd) |
MassFlow | imaging data; pixel coordinates are preserved on each spectrum |
.raw, .d, .wiff, .mzXML |
— | recognised and refused, naming the vendor and suggesting MSConvert |
MassFlow is a required dependency, but the installed release (0.1.x) is an imaging framework: it reads imzML/zarr/HDF5 and exposes no mzML or MGF reader. MSMCP therefore routes exactly what MassFlow supports through MassFlow, and owns small readers for the rest. That is a documented consequence of the available library, not a preference.
Two MassFlow behaviours are worth knowing before debugging an unexpected directory or log line:
- Importing MassFlow's data manager reconfigures the root logger to write to stdout, which would corrupt the stdio JSON-RPC framing.
massflow_iorepoints those handlers at stderr on import, and a test asserts that no handler in the process targets stdout after a read. - The same import creates a
logs/directory in the working directory. That is library behaviour MSMCP cannot suppress; it is gitignored and documented rather than hidden.
MS-Numpress-compressed mzML arrays require the optional pynumpress package and raise a missing-dependency error without it.
Plain-language definitions of the terms this document uses. Everything here is standard mass-spectrometry or MCP vocabulary; the list exists so a reader from either side — chemistry or agent tooling — is not stopped by the other side's jargon.
| Term | Meaning |
|---|---|
| MCP (Model Context Protocol) | An open protocol (JSON-RPC over stdio here) by which an LLM host launches and calls a tool server. |
| Host / client | The application driving the model and spawning the MCP server as a child process (e.g. Zed). |
| Tool call | One function the model invokes on the server, with typed arguments and a text result. |
| Data reference | An opaque token (ptr:spectrum:…) standing in for data held on the server, so spectra never enter the conversation. |
| Job ID | A handle for long-running work in flight; poll it with check_search_status. Distinct from a data reference (data at rest). |
| Provenance | The structured record attached to a result: operation, parameters, source file (with digest) and software versions. |
| m/z | Mass-to-charge ratio — the x-axis of a mass spectrum. |
| MS1 / MS2 | A survey scan (all ions) / a fragmentation scan of a chosen precursor. Library search compares MS2 spectra. |
| Precursor ion | The ion selected for fragmentation in an MS2 scan. |
| Adduct | The ionised form of a molecule, e.g. [M+H]+, [M+Na]+; it shifts the observed m/z. |
| Monoisotopic mass | The mass using the most abundant isotope of each element (the M peak). |
| M, M+1, M+2, … | The isotope envelope: peaks one, two, … neutrons heavier than M. Annotated to M+2 by default; a halogen-rich formula carries its signal in M+2 and M+4. |
| ppm error | Mass error in parts per million; MSMCP's default precursor gate is 5.0 ppm (class-dependent). |
| Cosine similarity | A 0–1 score for how well two spectra's matched peaks agree; 1.0 means the matched peaks account for all intensity in both. |
| Spectral library | A collection of reference spectra to search against; MSMCP reads MSP/NIST-style text (.msp, .msp.gz). |
| MSP / NIST text | The plain-text interchange format most public libraries ship as. |
| imzML | An open format for mass-spectrometry imaging data; MSMCP reads it via MassFlow. |
| p-value / q-value / FDR | Significance of a hit, and the false-discovery-rate control across hits. |
| Decoy | A shuffled, non-matching spectrum used to estimate the false-positive background. |
| Embedding / foundation model | A learned vector representation of a spectrum; optional, and requires real model weights. |
| Instrument class | A named assumption set (orbitrap, tof, ion_trap, …) that supplies the default thresholds a scan applies. |
| Allowed root | The single directory subtree every file-reading tool is confined to. |
Every significant object MSMCP derives carries a structured, immutable provenance record:
{
"operation": "load_spectrum",
"created_at": "2026-09-21T17:24:18.809783+00:00",
"parameters": {"spectrum_index": 0, "source_format": "mzML", "reader_backend": "msmcp.mzml"},
"sources": [{"path": "run42.mzML", "format": "mzML", "backend": "msmcp.mzml",
"size_bytes": 84213, "digest": "sha256:5b1f…"}],
"parents": ["ptr:spectrum:9f3c…"],
"model": null,
"software": {"name": "msmcp", "versions": {"python": "3.13.7", "msmcp": "1.0.0", "massflow": "0.1.2", "numpy": "2.3.4"}}
}A search result can therefore answer, mechanically: which query reference, which file and which reader produced it, with which scoring method and parameters, under which software versions — and, for embedding-scored work, which model and checkpoint. Digests are computed for sources up to 64 MiB; larger files report digest: null rather than silently omitting the field.
It is deliberately not a provenance framework: no graph, no query language, no storage. Records travel with the objects they describe.
LLM calls: predict_adduct_offset(adduct_string="[M+H]+")
→ "Adduct: [M+H]+
Polarity: positive
Charge state: +1
Exact mass shift (Δ): +1.007276 Da
m/z offset for neutral M: M + 1.007276 Da"
LLM calls: annotate_isotopes(identifier="C6H12O6")
→ "Monoisotopic mass: 180.0634 Da
| M | 180.0634 | 1.0000 |
| M+1 | 181.0668 | 0.0686 |
| M+2 | 182.0680 | 0.0143 |"
LLM calls: validate_precursor(theoretical_mass=180.0634, experimental_mass=180.0628)
→ "VALIDATION PASSED
Mass error: 3.33 ppm
Instrument class: generic (precursor tolerance 5.0 ppm)"
The isotope envelope is the exact multinomial expansion of each element's
isotopes, not a first-order approximation. That is what makes a halogen-rich
formula correct: chlorine and bromine have no single-neutron isotope, so their
signal sits in M+2 and M+4, and raising isotopologues reveals the M+4 peak a
C/H/N/O compound never has.
Hallucinated adducts (e.g. [M+H2O]+) are explicitly rejected with the list of supported ionisation pathways, rather than silently producing plausible-looking numbers.
SMILES → formula conversion uses RDKit when available (uv sync --extra chem); without it, annotate_isotopes falls back to a small static lookup table and asks the model to submit a formula instead of an unknown SMILES.
LLM calls: compute_cosine(
query_peaks=[[110.07, 40.0], [120.08, 100.0], [136.08, 60.0]],
reference_peaks=[[110.07, 40.0], [120.08, 100.0], [136.08, 60.0], [500.10, 55.0]],
scoring_method="dreams"
)
→ "Cosine Similarity (DreaMS): 0.8838
Scoring method: DreaMS deep embedding (1024-d, L2-normalised)"
scoring_method accepts "classical" (greedy peak matching within a Da tolerance, with unmatched-peak reporting), "dreams", or "lsm-ms2". The embedding methods require real inference: if the corresponding model is unavailable in production, the tool raises EmbeddingBackendUnavailable rather than returning a value that looks learned. Embeddings matter for the Dark Metabolome: learned representations express structural similarity, so a spectrum with no library twin can still rank meaningfully against its nearest chemical neighbours — something raw peak alignment cannot do.
Internals — the spectral foundation-model adapters (DreaMS, LSM-MS2, and the
test-only deterministic mocks), the JobExecutor interface, the testing and
linting story, and the full repository layout — live in
DEVELOPING.md, kept out of this document so it can stay the
user- and agent-facing story.
Forward focus: local MS database connectivity. Reading spectral libraries the user already has is under way: MSP/NIST-style text and MGF libraries are read from disk now, behind a LibraryProvider interface, alongside the acquisition readers. Still to come: a local SQLite peak store, so search_library covers the formats libraries ship as. MSMCP remains local, offline and self-contained: it ships no library data, downloads none, and never talks to a hosted service. See ARCHITECTURE.md → Direction.
- Search names which library answered it: the query spectrum is genuine data, read from disk through the ingestion layer or dereferenced from the store, so a missing or malformed query fails loudly before dispatch. A library path in a readable format (MSP/NIST text or MGF,
.msp/.msp.gz/.mgf/.mgf.gz) is read from disk through the sameSecurityPolicyas acquisitions — allowed root, size limit, read-only — and is refused withPathEscapeErrorif it resolves outside the allowed root. A path with no reader still falls back to a synthetic library, and the report says which path was used, in a banner ahead of any hit table. Still to come is the local SQLite peak store. - In-process job state: jobs live in server memory and do not survive a restart; the poller reports a lost job as failed rather than pending. A durable executor is a drop-in
JobExecutorimplementation. - MCP Tasks are not dispatchable by the installed SDK:
mcp2.1.1 ships the Tasks types but omitstasks/get,tasks/resultandtasks/cancelfrom its request unions, so no server can answer them. MSMCP keeps its snapshots in the MCPTaskshape and exposes the semantics throughcheck_search_statusinstead of inventing a protocol. - Readers MSMCP owns:
.mzML/.mzML.gzand.mgf/.mgf.gzare parsed by MSMCP itself, because MassFlow 0.1.x is imaging-only..imzMLgoes through MassFlow. Vendor formats are refused with conversion guidance. - Model adapters — DreaMS real, LSM-MS2 blocked upstream:
DreaMSInferenceEmbedderruns real transformer inference behind theSpectralEmbedderinterface (install thedreamspackage from source; weights auto-download). LSM-MS2 awaits a public weights release; its adapter activates viaMSMCP_LSM_MS2_CKPT. Backend resolution is governed byMSMCP_EMBEDDING_BACKEND(real/mock). Neither is required for core MSMCP to run or to be tested. - Whole-slide images: MassFlow materialises one placeholder per pixel when loading an imzML acquisition, so very large images are bounded by MassFlow's own behaviour.
- MS-Numpress-compressed mzML arrays need the optional
pynumpresspackage and report a missing-dependency error without it. - Hardware control is out of scope for this repository, not just for v1.0. Direct instrument control, actuation and the safety interlock layer that must precede them belong to a different project. MSMCP is a data and computation interface; the MCP application layer must never be the only barrier between a model and physical hardware. There is no instrument-control code here and none will be added.
See ARCHITECTURE.md for the full limitations list and the acceptance criteria (v1.0 and v1.1).
MSMCP is a local, single-user server: the MCP host spawns it as a child process with your credentials, so anything the server can do, a misused agent can do too. Keep these boundaries in mind:
- Trust boundary — anyone who can drive the LLM client can drive the server. Run it only on machines you own; it speaks stdio by design and must never be exposed as a network service.
- File access — every file-reading tool (
load_mzml_summary,load_spectrum,generate_qc_summary, and bothsearch_library's query and itsdatabase_filelibrary path) is confined to a single allowed root directory (src/msmcp/security.py). The default root is the server's working directory, override it withMSMCP_ALLOWED_ROOT. Paths that resolve outside that root — including symlinks that point outside it — are rejected withPathEscapeError, and for imzML the paired.ibdpayload is checked against the same root. Files are also capped by a size limit (MSMCP_MAX_FILE_SIZE_BYTES, default 500 MiB) and a per-request spectrum-count limit (MSMCP_MAX_SPECTRA); for compressed input — a.gzfile or a zlib-compressed mzML binary array — the size limit is enforced against the inflated bytes as they are read (bounded_streaminsrc/msmcp/security.py), so a small compressed file cannot expand past the budget. Access failures are reported truthfully and distinctly (InaccessiblePathErrorfor missing/unreadable paths,MalformedFileErrorfor unparseable data,UnsupportedFormatErrorfor vendor formats,MissingDependencyErrorwhen an optional decoder is absent) rather than as a generic failure. - Server-side data — registered references are bounded (
MSMCP_MAX_REFERENCES,MSMCP_MAX_TOTAL_BYTES), expire afterMSMCP_REFERENCE_TTL_SECONDS(default 3600), are held read-only, and are namespaced. Exceeding a limit refuses the request; it never silently discards data a caller still holds. Reference identifiers are opaque random tokens, never object ids, addresses or paths. - Execution — concurrent jobs are capped by the executor (default 4). A cancelled job's partial output is discarded rather than returned.
- Checkpoint loading — the LSM-MS2 adapter deserialises a checkpoint file (
torch.jit.load, falling back totorch.loadwithweights_only=True); checkpoint files can execute arbitrary code, so only pointMSMCP_LSM_MS2_CKPTat files you trust. A checkpoint that needs full-pickle loading is rejected unlessMSMCP_LSM_MS2_ALLOW_UNSAFE=1explicitly opts in — that fallback (torch.loadwithoutweights_only) can execute arbitrary code from the file. The DreaMS adapter downloads pre-trained weights from the upstream repository on first use — pin thedreamsinstall to a commit you trust. - Dependency side effects — importing MassFlow's data manager reconfigures the Python root logger to write to stdout and creates a
logs/directory in the working directory. MSMCP neutralises the first (it would corrupt the stdio framing) and documents the second. See Supported input formats. - No secrets — the server stores no credentials, requires no API keys, and makes no network calls of its own. This is a design constraint, not a current limitation: MSMCP is intended for onsite, offline use, so remote database adapters are explicitly out of scope (see ARCHITECTURE.md → Out of scope).
| Variable | Default | Effect |
|---|---|---|
MSMCP_ALLOWED_ROOT |
process working directory | Filesystem root every file-reading tool is confined to |
MSMCP_MAX_FILE_SIZE_BYTES |
500 MiB | Per-file size ceiling |
MSMCP_MAX_SPECTRA |
1000 | Spectra a single request may process (generate_qc_summary caps here and states the cap in its report) |
MSMCP_MAX_REFERENCES |
128 | Live server-side data references |
MSMCP_MAX_TOTAL_BYTES |
512 MiB | Summed payload size held by the reference store |
MSMCP_REFERENCE_TTL_SECONDS |
3600 | Reference lifetime (0 disables expiry) |
MSMCP_EMBEDDING_BACKEND |
real |
real requires real inference; mock selects the test/dev-only stand-ins |
MSMCP_LSM_MS2_CKPT |
unset | Path to a bring-your-own LSM-MS2 checkpoint |
MSMCP_LSM_MS2_ALLOW_UNSAFE |
unset | 1 opts into full-pickle checkpoint loading (unsafe) |
Instrument-specific thresholds are deliberately not environment variables:
they are the per-call instrument_class argument described in
Instrument classes, so the assumption behind a result
travels with the result rather than with the server's process environment.
MIT License — Copyright (c) 2026 Eric Janusson.