Server data from the Official MCP Registry
Plant phenotyping via PlantCV — returns traits plus the segmentation overlay they came from
About
Plant phenotyping via PlantCV — returns traits plus the segmentation overlay they came from
Security Report
Valid MCP server (1 strong, 1 medium validity signals). No known CVEs in dependencies. Package registry verified. Imported from the Official MCP Registry. Trust signals: trusted author (11/11 approved).
4 files analyzed · 1 issue found
Security scores are indicators to help you make informed decisions, not guarantees. Always review permissions before connecting any MCP server.
How to Install
Add this to your MCP configuration file:
{
"mcpServers": {
"io-github-musharna-plantcv-mcp": {
"args": [
"plantcv-mcp"
],
"command": "uvx"
}
}
}Documentation
View on GitHubFrom the project's GitHub README.
plantcv-mcp
Plant phenotyping over MCP — traits, plus the segmentation overlay they were measured from.
PlantCV 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.
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" |
|---|---|
![]() | ![]() |
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
pip install plantcv-mcp
Or from the repository, to track unreleased changes:
uv add git+https://github.com/musharna/plantcv-mcp
Or from a local checkout:
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
claude mcp add plantcv -- plantcv-mcp
Claude Desktop — add to claude_desktop_config.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:
{
"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, 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:
{
"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:

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:
{"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 asnullwithstem_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 to0.0;tangent_window_exceeds_segmentsays so instead of letting the zero read as a measurement.prune_sizedecides how many segments a skeleton has. When the count changes by more than 30% at twice the value,prune_size_sensitivetells 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.
{
"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 ifwhite − darkis not positive everywhere: PlantCV would clip the degenerate case to 1.0 silently), or cast to float with theuncalibrated_cubewarning — 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:
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, 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 for the full statement.
More
- CHANGELOG.md — what changed, and why
- 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.
Reviews
No reviews yet
Be the first to review this server!
More Developer Tools MCP Servers
Git
Freeby Modelcontextprotocol · Developer Tools
Read, search, and manipulate Git repositories programmatically
Fetch
Freeby Modelcontextprotocol · Developer Tools
Web content fetching and conversion for efficient LLM usage
Toleno
Freeby Toleno · Developer Tools
Toleno Network MCP Server — Manage your Toleno mining account with Claude AI using natural language.
mcp-creator-python
Freeby mcp-marketplace · Developer Tools
Create, build, and publish Python MCP servers to PyPI — conversationally.
MCP Marketplace
Freeby mcp-marketplace · Developer Tools
Search and install MCP servers from inside your AI client.
MarkItDown
Freeby Microsoft · Content & Media
Convert files (PDF, Word, Excel, images, audio) to Markdown for LLM consumption


