Metadata-Version: 2.5
Name: plantcv-mcp
Version: 1.4.0
Summary: MCP server exposing PlantCV as a measurement instrument — traits plus the segmentation overlay they came from
Project-URL: Homepage, https://github.com/musharna/plantcv-mcp
Project-URL: Repository, https://github.com/musharna/plantcv-mcp
Project-URL: Issues, https://github.com/musharna/plantcv-mcp/issues
Project-URL: Changelog, https://github.com/musharna/plantcv-mcp/blob/master/CHANGELOG.md
Author: Jaret Arnold
License-Expression: MIT
License-File: LICENSE
License-File: NOTICE
Keywords: computer-vision,image-analysis,mcp,model-context-protocol,phenotyping,plant-science,plantcv
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
Classifier: Topic :: Scientific/Engineering :: Image Processing
Requires-Python: >=3.11
Requires-Dist: mcp<3,>=2
Requires-Dist: numpy>=1.26
Requires-Dist: opencv-python>=4.9
Requires-Dist: plantcv==4.11.3
Requires-Dist: typing-extensions>=4.12
Description-Content-Type: text/markdown

# plantcv-mcp

**Plant phenotyping over MCP — traits, plus the segmentation overlay they were measured from.**

[![ci](https://github.com/musharna/plantcv-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/musharna/plantcv-mcp/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/plantcv-mcp)](https://pypi.org/project/plantcv-mcp/)
[![python](https://img.shields.io/pypi/pyversions/plantcv-mcp)](https://pypi.org/project/plantcv-mcp/)
[![license](https://img.shields.io/pypi/l/plantcv-mcp)](LICENSE)
[![Glama](https://glama.ai/mcp/servers/musharna/plantcv-mcp/badges/score.svg)](https://glama.ai/mcp/servers/musharna/plantcv-mcp)
[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21713516.svg)](https://doi.org/10.5281/zenodo.21713516)

<!-- mcp-name: io.github.musharna/plantcv-mcp -->

[PlantCV](https://plantcv.org) as an MCP **measurement instrument**: it returns plant trait
numbers **and the picture they were computed from**, and refuses to return numbers when the
segmentation is degenerate.

> Unofficial. Not affiliated with, endorsed by, or sponsored by the Donald
> Danforth Plant Science Center or the PlantCV maintainers. See [NOTICE](https://github.com/musharna/plantcv-mcp/blob/master/NOTICE).

## Why you are handed the overlay

Red marks the pixels that were measured. Both images below come from the same file and the
same threshold method — the only difference is one parameter.

| ✅ `channel="a", object_type="dark"`                                                                                   | ❌ `channel="s", object_type="dark"`                                                                                     |
| ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| ![correct segmentation](https://raw.githubusercontent.com/musharna/plantcv-mcp/master/docs/assets/overlay-correct.png) | ![inverted segmentation](https://raw.githubusercontent.com/musharna/plantcv-mcp/master/docs/assets/overlay-inverted.png) |
| Mask covers **3.1%** of the frame, 9 components. `area=32427`                                                          | Mask covers **96.1%** — it is the **background**. `area=1007829`                                                         |

The failure on the right is what this server exists to prevent. Without the picture, both
runs return seventeen traits with correct units and entirely believable magnitudes. The one
on the right is measuring the wall behind the plants.

`segment()` returns the overlay and diagnostics but **no traits**. `measure()` requires the
`session_id` that `segment()` mints. You cannot get a number without first being handed the
image it came from.

That is not a style preference. Measured on real images with PlantCV 4.11.3:

| failure                           | what you get without the overlay                            |
| --------------------------------- | ----------------------------------------------------------- |
| four-view render, whole-image ROI | 17 plausible traits describing four merged plants           |
| plant clipped by the frame        | size traits that are silently lower bounds                  |
| empty mask                        | 17 traits of zeros, with PlantCV reporting `in_bounds=True` |

All three produce correctly-united, entirely believable numbers.

## Install

```bash
pip install plantcv-mcp
```

Or from the repository, to track unreleased changes:

```bash
uv add git+https://github.com/musharna/plantcv-mcp
```

Or from a local checkout:

```bash
uv add /path/to/plantcv-mcp
```

Requires Python 3.11+. Installing pulls PlantCV and its scientific stack
(scikit-image, dask, scipy), so the first install is not fast.

## Configure your MCP client

The server speaks stdio. The console script installed by the package is
`plantcv-mcp`.

**Claude Code**

```bash
claude mcp add plantcv -- plantcv-mcp
```

**Claude Desktop** — add to `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "plantcv": {
      "command": "plantcv-mcp"
    }
  }
}
```

If the executable is not on your `PATH` (common when it lives in a project
virtualenv), give the absolute path to it, or invoke it through uv:

```json
{
  "mcpServers": {
    "plantcv": {
      "command": "uv",
      "args": ["run", "--directory", "/path/to/plantcv-mcp", "plantcv-mcp"]
    }
  }
}
```

Verify it is wired up by calling `list_methods()`, which reports the channels,
the methods, and the pinned PlantCV version.

## Tools

| tool                                                                    | returns                                                 |
| ----------------------------------------------------------------------- | ------------------------------------------------------- |
| `suggest_segmentation(image_path, channel, method)`                     | contact sheets, and what each `object_type` would yield |
| `segment(image_path, channel, method, ...)`                             | overlay + diagnostics + warnings — **no traits**        |
| `refine(session_id, ops)`                                               | a NEW session with a cleaned-up mask, plus its overlay  |
| `measure_morphology(session_id, prune_size, tangent_size, ...)`         | leaf/stem skeleton traits + the numbered-segment overlay |
| `measure(session_id, analyses, px_per_mm, ...)`                         | traits, or a raised error on a degenerate mask          |
| `calibrate_scale_from_marker(image_path, x, y, w, h, marker_length_mm)` | `px_per_mm` from a marker of known real size            |
| `measure_regions(session_id, nrows, ncols, ...)`                        | one row per plant in a tray (RGB traits, thermal temperatures or HSI index stats), plus the numbered overlay |
| `measure_images(image_paths, channel, method, ...)`                     | one recipe across many images; traits only where valid  |
| `segment_hyperspectral(envi_path, index, threshold, ...)`               | an HSI session from a spectral-index threshold + pseudo-RGB overlay |
| `measure_spectral(session_id, indices, ...)`                            | index statistics (and, opt-in, per-band reflectance)    |
| `segment_thermal(path, min_c, max_c, ...)`                              | a thermal session from a °C band + grey-frame overlay   |
| `measure_thermal(session_id, ...)`                                      | max/min/mean/median °C under the mask                   |
| `list_methods()`                                                        | channels, methods, object types, pinned PlantCV version |

Typical loop: `suggest_segmentation` → `segment` → **look at the overlay** → `segment` again
with a different channel, method or polarity if it is wrong → `measure`.

### `segment` parameters

| parameter     | default  | what it does                                                    |
| ------------- | -------- | --------------------------------------------------------------- |
| `image_path`  | required | image to read from the host filesystem                          |
| `channel`     | required | one of `l a b h s v` — never guessed for you                    |
| `method`      | required | one of `otsu triangle mean gaussian`                            |
| `object_type` | `"dark"` | which side of the threshold is the plant. **See below**         |
| `fill_size`   | `200`    | drops components smaller than this; can erase a small specimen  |
| `ksize`       | `11`     | neighbourhood size, `mean` and `gaussian` only                  |
| `offset`      | `2`      | constant subtracted from the local mean, `mean`/`gaussian` only |

The `segment()` response for the image at the top of this page — verbatim, apart from a
shortened `session_id` and an elided warning message:

```json
{
  "session_id": "9d2384c8-…",
  "channel": "a",
  "method": "otsu",
  "object_type": "dark",
  "fill_size": 200,
  "mask_fraction": 0.031,
  "component_count": 9,
  "major_object_count": 4,
  "largest_area": 8628,
  "overlay_scale": 1.0,
  "overlay_png_bytes": 748233,
  "warnings": [
    {
      "code": "multi_specimen",
      "message": "4 comparably-sized objects detected (areas: [8628, 7981, 7106, 6748]). …"
    }
  ]
}
```

The overlay arrives alongside this as a second content block, as an image.

## Getting the polarity right

`object_type` decides which side of the threshold is the plant, and it is the easiest way to
get a confidently wrong answer — that is the right-hand image at the top of this page.

Two things guard against it. `suggest_segmentation` reports what **both** polarities yield on
your image before you commit, alongside a contact sheet of every colourspace:

![colourspace contact sheet](https://raw.githubusercontent.com/musharna/plantcv-mcp/master/docs/assets/suggest-colorspaces.png)

And `segment` emits an `implausible_coverage` warning when the mask covers more than half the
frame. Neither refuses the measurement, because a macro shot of a single leaf legitimately
fills the frame — they make the choice visible rather than making it for you.

`fill_size` deletes any component smaller than itself, so a small specimen can vanish
entirely. When that happens `segment` reports `fill_erased_mask` and names the size to drop
below, rather than letting it look like a bad channel choice.

## Refining a mask

When the overlay is nearly right — a hole in a leaf, specks on the background, a
pot rim segmented alongside the plant — `refine()` fixes the mask instead of sending
you back to hunt for a threshold:

```json
{"session_id": "…", "ops": [{"op": "fill_holes"}, {"op": "keep_largest", "n": 1}]}
```

Ops run in the order given: `fill_holes`, `fill(size)`, `erode(ksize, iterations)`,
`dilate(ksize, iterations)`, `opening(ksize)`, `closing(ksize)`, `median_blur(ksize)`,
and `keep_largest(n)`; `list_methods()` documents each parameter's constraints.
Every op is validated before any runs — PlantCV silently does nothing for
`fill(size=-1)` or `erode(iterations=0)`, and a no-op recorded as a refinement is a
lie — and a refinement that leaves no measurable plant is **refused** rather than
turned into a session that measures zeros.

`refine()` mints a **new** session and returns its overlay; the original stays
measurable, so a refinement you dislike is simply discarded. Trait tables from a
refined session carry `lineage`, the ops that produced their mask, so two tables
made differently can be told apart.

## Morphology: leaves, stem, branch points

`measure_morphology(session_id)` skeletonises the mask and returns PlantCV's
skeleton traits for **one plant**: per segment, path length, euclidean length,
curvature, angle, tangent angle and insertion angle; per plant, stem height, length
and angle, tip and branch-point counts, cycles, and segment widths. It returns the
**numbered-segment overlay** with the table — a segment `id` is the number drawn on
the picture — because a per-segment number is unreadable without it.

Three things here are guards, not pass-throughs, all measured on PlantCV 4.11.3
against a synthetic plant of known geometry:

- A perfectly vertical stem makes PlantCV report `stem_angle = -14373°`. That is not
  an angle, so it is returned as `null` with `stem_angle_undefined`.
- `tangent_size` (default 25 px, chosen from a bias sweep) is the window PlantCV fits
  tangents on, from **each** end of a segment. A window longer than half a leaf
  collapses that leaf's insertion angle to `0.0`; `tangent_window_exceeds_segment`
  says so instead of letting the zero read as a measurement.
- `prune_size` decides how many segments a skeleton has. When the count changes by
  more than 30% at twice the value, `prune_size_sensitive` tells you the table
  describes the parameter, not the plant. A skeleton PlantCV cannot analyse at all
  is refused with those counts, not a stack trace.

Multi-plant masks are refused by name — use `measure_regions()` for a tray or
`refine(keep_largest)` to isolate one plant first. Lengths scale with `px_per_mm`;
angles are always degrees.

## What it measures

One `measure()` call returns seventeen traits, each with a unit.

| group         | traits                                                                                                   |
| ------------- | -------------------------------------------------------------------------------------------------------- |
| size          | `area`, `convex_hull_area`, `perimeter`, `total_edge_length`, `width`, `height`, `longest_path` (pixels) |
| shape         | `solidity`, `convex_hull_vertices`, `ellipse_eccentricity` (unitless)                                    |
| ellipse fit   | `ellipse_major_axis`, `ellipse_minor_axis` (pixels), `ellipse_angle` (degrees)                           |
| position      | `center_of_mass`, `ellipse_center` (x, y)                                                                |
| PlantCV flags | `in_bounds`, `object_in_frame`                                                                           |

The last two are PlantCV's own flags. They are passed through as **information, never as
validity signals** — on an all-zero mask PlantCV reports both as `True` while returning
seventeen zeros. They are bounds checks, not success checks.

Passing `analyses=["size", "color"]` adds hue, saturation and value statistics —
`hue_circular_mean`, `hue_circular_std`, `hue_median` (degrees), `saturation_mean`,
`saturation_median`, `value_mean`, `value_median` (percent). The three frequency histograms
that accompany them total 692 numbers, so they are withheld unless you ask for them with
`include_histograms=true`.

### Real-world units

**Traits are in pixels by default, and pixel sizes are not comparable between images shot at
different distances or zoom levels.** Pass `px_per_mm` to `measure()` and spatial traits come
back in `mm` and `mm2`:

```
measure(session_id, px_per_mm=12.5)
  area    207.533 mm2     (32427 pixels)
  width    54.880 mm      (686 pixels)
```

Lengths divide by `px_per_mm`, areas by its square. That distinction is a hard-coded table
rather than something inferred from PlantCV's unit strings, because PlantCV labels **both**
`area` and `width` as `"pixels"` — scaling everything with that label linearly would leave
every area wrong by exactly a factor of `px_per_mm`, plausibly and silently. Positions
(`center_of_mass`, `ellipse_center`) stay in pixels, since a millimetre coordinate means
nothing without a defined origin.

If you have a marker of known real size in the frame — a coin, a printed disc — put a box
around it and let the server measure it:

```
calibrate_scale_from_marker(image_path, x=100, y=100, w=100, h=100, marker_length_mm=20)
  -> px_per_mm 4.05, marker_length_px 81
```

`marker_length_mm` is the marker's longest real dimension. **Check `marker_length_px` against
what you expect**, because a wrong scale silently rescales every trait you measure afterwards.
The region is cropped before thresholding, so nothing outside your box can be selected;
PlantCV's own `report_size_marker_area` takes an ROI instead, and measured against a disc of
known 80 px diameter it returns 348 with a tight ROI — a silent 4.35× error. If the detected
object reaches the crop edge you get a `marker_touches_crop_edge` warning, which usually means
the polarity is wrong and the background was measured.

## Colour correction

`segment(..., color_correct=true)` detects a Macbeth-style ColorChecker in the frame and
corrects to a standard reference, which is what makes colour traits comparable between images
shot under different lighting. `measure()` re-applies the same correction, so traits are always
measured on the pixels the mask was drawn on.

If no card is found this **raises** rather than quietly measuring the uncorrected image —
returning colour traits that look corrected and are not would be the same kind of confident
wrongness as an inverted mask.

## Measuring many images

`measure_images(image_paths, channel, method, ...)` applies one fixed recipe across up to 200
images.

This is the one place the two-step discipline cannot hold literally: nobody reviews two hundred
overlays. So the overlay is replaced by the only honest substitute — **every image runs the same
guards as `segment()`, and any image that trips a blocking guard comes back with no traits at
all**, just a reason and an instruction to inspect it individually. Advisory warnings such as
`multi_specimen` are attached to the traits rather than suppressing them.

```json
{
  "summary": {
    "submitted": 2,
    "measured": 1,
    "needs_review": 1,
    "review_paths": ["blank.png"]
  },
  "results": [
    {
      "image_path": "blank.png",
      "measured": false,
      "traits": null,
      "refused_because": "empty_mask — traits withheld because the mask probably does not describe the plant."
    }
  ]
}
```

Settle the recipe on one representative image with `suggest_segmentation` and `segment` first,
looking at the overlay, then apply it here. A batch never returns a number the server could not
validate — which is weaker than a human looking at a mask, and is stated plainly rather than
implied.

## Hyperspectral and thermal

ENVI cubes (`.raw` + `.hdr`; give either path) segment by a **spectral index**:
`segment_hyperspectral(envi_path, index="ndvi", threshold=…)` computes one of
PlantCV's 31 indices, thresholds it, and returns the overlay on the cube's
pseudo-RGB. Two measured facts drive the guards here:

- Cubes are usually **integer counts**, and an index computed on integer data
  wraps around silently — NDVI on a uint16 test cube read 65.3 on a [-1, 1]
  index. So the cube is either **calibrated** to reflectance from
  `white_reference` + `dark_reference` (refused if `white − dark` is not positive
  everywhere: PlantCV would clip the degenerate case to 1.0 silently), or cast to
  float with the `uncalibrated_cube` warning — the indices are then relative, not
  reflectance.
- An index the cube's wavelength range cannot support is **refused by name**,
  not returned as null.

`measure_spectral(session_id, indices=[…])` reports mean/median/std/min/max per
index; the full per-band spectrum (hundreds of numbers per list) is opt-in via
`include_spectrum`, with `band_count` always reported.

Thermal frames — FLIR radiometric `.jpg` (via `flyr`), `.csv`, or `.npz` of
degrees Celsius — segment **by temperature**: `segment_thermal(path, min_c,
max_c)`. A thermal frame is a different sensor from the RGB camera, so a mask is
never borrowed from an RGB session. `measure_thermal` reports max/min/mean/median
°C via PlantCV's `analyze.thermal`; validated against PlantCV's own test frame to
six decimal places and against a real FLIR JPEG. Sessions are typed (`rgb`, `hsi`,
`thermal`) and every measurer refuses the wrong kind naming the right tool.

## Crash containment: the analysis worker

Every PlantCV analysis (`measure`, `measure_regions`, `measure_morphology`,
`measure_images`, `refine`) runs in a **worker subprocess** by default. Two things
follow. A native crash inside PlantCV/OpenCV — the class 0.5.0 closed one instance
of by validating `rect_grid` geometry — becomes a tool error (*"the analysis worker
died during 'measure' (signal 11) … the server is still running"*) and the next call
starts a fresh worker; the server itself never executes native analysis code. And
PlantCV's process-global state (`pcv.outputs`, its cached colour palette, its
sample label) lives in the worker, not in the server.

Measured cost on a 3000×3000 image: **+7.7 %** wall time per `measure()` (the arrays
cross a pipe), against a gate of 25 % that would have made it opt-in. The first
analysis after start-up pays the worker's import (~1 s). Turn it off with
`plantcv-mcp --no-isolate` or `PLANTCV_MCP_ISOLATE=0`.

## Restricting what the server may read

By default the server reads any image the host user can read (below). To confine
it, name one or more directories:

```bash
plantcv-mcp --root /data/phenotyping --root /data/trials
# or
PLANTCV_MCP_ROOTS=/data/phenotyping:/data/trials plantcv-mcp
```

Every path argument — `segment`, `suggest_segmentation`,
`calibrate_scale_from_marker`, every entry of `measure_images` — is resolved with
symlinks and `..` followed **first**, then checked against the roots; a symlink
inside a root pointing outside is refused, and a batch with one stray path is
refused whole before anything is read. `list_methods()` reports the policy as
`read_roots`.

## Security and trust boundary

**This server reads image files anywhere on the host filesystem, and returns
them to the model as images.**

`suggest_segmentation` and `segment` take an `image_path` and pass it straight to
PlantCV's reader. There is no directory allow-list and no sandbox. Any path the
model asks for — that the operating-system user running the server can read — will
be decoded and returned as a base64 image in the model's context.

Practical consequences:

- Treat it like any other local filesystem MCP server. Run it as a user whose
  read access you are comfortable exposing to the model driving it.
- A prompt-injected or adversarial model can use it to view arbitrary **image**
  files on the machine. Non-image files fail to decode and raise, but the error
  message discloses whether the path exists.
- Do not run it as root, and do not expose it to untrusted input on a machine
  holding sensitive imagery.

Restricting reads to a configured root directory is a candidate for a future
release; it is deliberately **not** implemented today, and this section exists so
that is a decision you make rather than a surprise you discover.

## Limitations

`measure()` is single-ROI by design: it treats the whole image as one region,
so on a tray it merges every plant into one object. Use `measure_regions()` for
multi-plant images — it measures each region separately and returns an overlay
with the regions outlined and numbered.

Morphology traits are single-plant (`measure_morphology()` refuses a tray); per-region
morphology is not implemented. Hyperspectral support covers ENVI cubes; other cube
formats (nd2, ArcGIS) and photosynthesis (PSII) data are not exposed.

Sessions are in-memory and capped (8 by default, LRU-evicted). They do not
survive a server restart.

## Attribution and licensing

This project is MIT licensed. It depends on
[PlantCV](https://github.com/danforthcenter/plantcv), which is licensed under the
**Mozilla Public License 2.0**. No PlantCV source is vendored or redistributed
here — it is an ordinary runtime dependency — so the MIT license applies to this
project's own files. See [NOTICE](https://github.com/musharna/plantcv-mcp/blob/master/NOTICE) for the full statement.

## More

- [CHANGELOG.md](https://github.com/musharna/plantcv-mcp/blob/master/CHANGELOG.md) — what changed, and why
- [docs/MUTATION-CHECKS.md](https://github.com/musharna/plantcv-mcp/blob/master/docs/MUTATION-CHECKS.md) — every guard disabled on purpose, and
  the test that went red for it. A guard whose test passes with the guard removed is not a test.

Images on this page are rendered from `tests/fixtures/multi_specimen.png`, an original render
by the author, and regenerate from committed code.
