genicam-mcp
# genicam-mcp
**Vendor-neutral industrial camera control via GenICam GenTL.**
An MCP server that enumerates, connects to, configures, and captures from industrial
cameras through the **GenICam GenTL standard** — not through a single vendor's SDK.
Point it at any GenTL producer (`.cti`) and you get the camera's **entire GenICam
feature tree**: exposure, gain, trigger, ROI, pixel format, and everything else the
camera exposes.
## Why this exists
Existing camera MCP servers bind to one vendor's SDK (typically `pypylon`, Basler-only)
and expose a single "grab a frame" call. This one goes through the vendor-neutral GenTL
layer instead, so one code path is meant to drive any GenTL-compliant camera — Basler,
HIKrobot, Teledyne FLIR, Allied Vision, XIMEA — regardless of vendor. (Actually tested
producers are listed under [Verified](#verified) below.) It also exposes feature control,
not just capture.
**No vendor SDK is imported.** The only path to the hardware is `harvesters` → `.cti`.
That constraint is the point of the project.
## Install
Requires **Python 3.12** (the `genicam` wheels this depends on are version-specific).
```bash
git clone https://github.com/finnhyun12/genicam-mcp.git
cd genicam-mcp
uv venv --python 3.12
uv sync
```
## Claude Desktop setup
Add to your `claude_desktop_config.json`:
```json
{
"mcpServers": {
"genicam": {
"command": "uv",
"args": ["--directory", "/absolute/path/to/genicam-mcp", "run", "genicam-mcp"]
}
}
}
```
If `uv` is not on your PATH, point at the venv interpreter directly:
```json
{
"mcpServers": {
"genicam": {
"command": "/absolute/path/to/genicam-mcp/.venv/bin/python",
"args": ["-m", "genicam_mcp.server"]
}
}
}
```
On Windows use `.venv\\Scripts\\python.exe` and escape backslashes in JSON.
## Tools
| Tool | Purpose |
|---|---|
| `add_producer(cti_path)` | Register a GenTL producer (`.cti`). Call this first. Multiple producers may be registered. |
| `discover_cameras()` | List cameras: `index`, `vendor`, `model`, `serial`, `tl_type`. |
| `connect(index)` | Open a camera. Required before feature access or capture. |
| `disconnect()` | Release the camera. |
| `list_features(category=None)` | Walk the node map. Returns name, type, readable/writable, current value, range or options. |
| `get_feature(name)` | Read one feature with its type and `min`/`max` (or enum options). |
| `set_feature(name, value)` | Write any GenICam feature — `ExposureTime`, `Gain`, `TriggerMode`, `PixelFormat`, `Width`… Values are cast to the node's type. |
| `grab(save_path=None)` | Capture one frame → PNG. Returns `width`, `height`, `pixel_format`, `saved_path`. |
Feature names are **case-sensitive** and vary by camera. Use `list_features()` to see
what a given camera actually offers rather than guessing.
## Finding a `.cti` producer
Vendor SDKs install their producers and register the directory in the
`GENICAM_GENTL64_PATH` environment variable:
```bash
# Linux / macOS
echo $GENICAM_GENTL64_PATH
```
```powershell
# Windows
$env:GENICAM_GENTL64_PATH
```
Common locations:
| Vendor | Producer |
|---|---|
| Allied Vision Vimba X | `.../Vimba X/cti/VimbaGigETL.cti`, `VimbaUSBTL.cti` |
| Basler pylon | `.../pylon 6/Runtime/x64/ProducerGEV.cti`, `ProducerU3V.cti` |
| HIKrobot MVS | `.../Common Files/MVS/Runtime/Win64_x64/MvProducerGEV.cti`, `MvProducerU3V.cti` |
If `add_producer` is given a bad path, the error message lists the `.cti` files it
found on your machine.
## Demo without hardware
Allied Vision **Vimba X** ships a *Camera Simulator TL* — a GenTL producer that
presents simulated cameras with real test-pattern frames. No camera required.
1. Install [Vimba X](https://www.alliedvision.com/en/support/software-downloads/vimba-x-sdk/) (free).
2. Register the simulator producer and grab a frame:
```
add_producer("C:\\Program Files\\Allied Vision\\Vimba X\\cti\\VimbaCameraSimulatorTL.cti")
discover_cameras()
connect(0)
set_feature("TestPattern", "GreyVerticalRamp")
grab("frame.png")
```
This is the exact producer path used for the end-to-end verification below. On that
machine it enumerated three simulated cameras; index 0 was
`Camera Simulator (G1-030_VSWIR)` at 656×520 `Mono8`. `TestPattern` accepts `Off`,
`GreyVerticalRamp`, `GreyVerticalRampMoving`, `ColorVerticalBar`, `ColorHorizontalBar`,
and `ColorVerticalBarMoving` — check `get_feature("TestPattern")` on your install rather
than assuming, since the simulator's model list can differ by Vimba X version.
> Note: a vendor's *virtual camera* feature is not the same thing as a simulator
> **transport layer**. Basler's pylon Camera Emulator (`PYLON_CAMEMU`) and HIKrobot's
> MVS Virtual Device both live above the GenTL layer and are visible only through
> their own vendor SDKs — GenTL consumers cannot see them. Vimba X's Camera Simulator
> is itself a `.cti`, which is why it works here.
## Verified
- ✅ End-to-end (enumerate → connect → feature R/W → grab → PNG):
Allied Vision Vimba X — Camera Simulator TL (GenTL producer)
- ✅ Producer load & camera enumeration:
Basler pylon ProducerU3V.cti
> genicam-mcp is built against the GenICam GenTL standard and is
> expected to work with any compliant producer (Basler, HIKrobot,
> Teledyne FLIR, Allied Vision, XIMEA, …). Only the producers listed
> above are actually tested; others are unverified — field reports welcome.
## Development
```bash
uv run pytest # fake-GenTL suite; no hardware, no SDK
```
The real-producer suite is gated on an env var so day-to-day runs stay fast:
```bash
GENTL_CTI=/path/to/Producer.cti uv run pytest -m real_producer -v -s
```
If `GENTL_CTI` is set but no camera is found, the suite **fails** rather than skipping —
a gate that silently passes is not a gate.
## Known quirks
These are documented because each one *looks* like a broken server when you hit it
cold. Knowing them in advance turns a bug report into a shrug.
- **First `add_producer` can be slow.** Basler's `ProducerU3V.cti` took over 60 s on its
first load (USB bus enumeration). Nothing is wrong — it is enumerating the bus. Allow
a generous tool timeout on the first call; a short client timeout will cancel the
request and can tear down the MCP session with it.
- **A bad producer can take the process down.** `MvFGProducerCML.cti` segfaults
(`0xC0000005`) when loaded without Camera Link hardware. This happens inside the
native library, so no Python `try/except` can catch it — register only producers that
match your hardware.
- **Trigger mode blocks capture.** If `TriggerMode` is `On`, `grab()` waits for a
trigger and then times out. Set `set_feature("TriggerMode", "Off")` for free-run.
## Implementation notes
Three findings shaped this code. They are recorded here because none of them is
visible from the source alone, and each cost real time to track down.
**A vendor "virtual camera" is not a simulator transport layer.** Basler's pylon Camera
Emulator (`PYLON_CAMEMU`) and HIKrobot's MVS Virtual Device both look like the answer to
"test without hardware" — and both are invisible to GenTL. They live *above* the GenTL
boundary, inside the vendor SDK, so only that vendor's own tools can see them. Measured
on HIKrobot MVS: the vendor SDK enumerated the virtual camera (`MV_VIR_GIGE_DEVICE` → 1
device, and only when run elevated), while `MvProducerGEV.cti` reported 0 through the
same machine. Vimba X's Camera Simulator works because it *is* a `.cti`. The question to
ask of any "virtual camera" feature is not whether it exists but which layer it lives in.
**Import the native extensions at module load, never inside a tool call.** Deferring
`import harvesters` into the tool body seems tidy — it lets the module import cleanly on
machines with no SDK. But FastMCP runs tool functions off the event loop, and loading the
`genicam` native extension there **hangs forever**: the server accepts the
`CallToolRequest` and then simply never answers. A silent hang is far worse than an
immediate error, because the client only sees a timeout and the user has nothing to go
on. So the import happens at module load and the failure reason is stashed and raised, as
a readable message, at first use.
**A GenTL buffer is borrowed, not owned.** `ia.fetch()` hands out a buffer from the
acquisition queue; once it is returned, the memory is recycled. Keeping a numpy view past
that point does not raise — it silently yields whatever the driver wrote next, which in
practice means an all-black frame. This surfaced only against the real Vimba simulator,
never against the fakes, because a fake buffer is just a live numpy array. The fake now
zeroes its data on `__exit__` to model the real lifetime contract, so the failure is
reproducible without hardware, and `test_real_producer.py` asserts the frame is not
uniformly zero. **A capture test that only checks "a PNG exists" passes on a black
image.**
## Scope
One camera at a time; one frame per `grab()`. Simultaneous multi-camera acquisition,
async streaming, GUI, and web serving are deliberately out of scope.
## License
MIT — see [LICENSE](LICENSE).
TDQS
Scored across 8 tools
Each tool has a clearly distinct purpose: registration (add_producer), discovery (discover_cameras), connection (connect/disconnect), feature inspection (list_features, get_feature), feature modification (set_feature), and image capture (grab). No overlap.
All tool names follow a consistent lowercase snake_case verb_noun pattern (e.g., add_producer, discover_cameras, set_feature). No mixing of conventions.
8 tools is well-scoped for a camera control server. Each tool serves a necessary role without redundancy or bloat.
Covers the full lifecycle: producer registration, camera discovery, connection, feature configuration, image capture, and disconnection. Minor gap: no built-in support for continuous or burst capture, but single-frame grab and trigger control via set_feature suffice for basic use.