Skip to main content
Glama
fc0web

rei-lads-mcp

by fc0web
README.md
# rei-lads-mcp

> ⚠️ **v0.1.0-preview — early-feedback release. NOT for production use.**
> API is not stable before v1.0.0. Breaking changes may land in any 0.x version.
> X.509 certificate authentication is **not supported** in the 0.1.x line
> (username/password only). See [ROADMAP.md](ROADMAP.md) for Phase 5 items.

An MCP server that exposes **OPC UA LADS** (Laboratory Agnostic Device
Standard) instruments to LLM agents with two Rei-native disciplines
baked in:

1. **Approval-gated execution** — every state-changing tool validates its
   operation name against a server-declared enum (LADS state-machine
   methods, program templates). Arbitrary strings are rejected at the
   MCP boundary, before any network call fires.
2. **Witnessed provenance** — every state-changing call emits a SHA-256
   record (initiator, node, operation, params, samples, timestamp) that
   is persisted to a local append-only JSONL and retrievable by prefix.

Modeled on the LADS reference implementations at
[`opcua-lads`](https://github.com/opcua-lads) and grounded in the
[Rei / D-FUMT](https://github.com/fc0web/rei-lang) approval-gate +
`witnessed by` design.

## Install

Requires Python 3.12+.

```bash
python -m venv .venv
./.venv/bin/pip install -r requirements.txt   # Linux/macOS
./.venv/Scripts/pip install -r requirements.txt   # Windows
```

## Quick start

Boot a LADS reference server (for example, `lads-viscometer` from
[`opcua-lads/lads-server-collection`](https://github.com/opcua-lads/lads-server-collection)),
then run:

```bash
python server.py
```

This starts a stdio MCP server. Point any MCP client at it.

Programmatic sanity check (no MCP client required):

```bash
python test_tools_list.py    # 15 tools registered, schemas generated
python test_live_phase4.py   # end-to-end against a running LADS server on
                             # opc.tcp://localhost:4840
```

## Tools (15)

**Connection**
- `lads_connect(alias, server_url, user?, password?, wait_timeout_s?, cert_path?, key_path?)`
- `lads_register_alias(alias, server_url, description?, user?, password?)`
- `lads_list_aliases()`
- `lads_connect_by_alias(alias, wait_timeout_s?)`

**Discovery** (read-only)
- `lads_list_devices(alias)`
- `lads_get_functional_units(alias, device_index)`
- `lads_get_sensor_value(alias, device_index, unit_index, function_index)`
- `lads_get_state(alias, device_index, unit_index)`
- `lads_get_state_machine_methods(alias, device_index, unit_index)`
- `lads_list_program_templates(alias, device_index, unit_index)`
- `lads_list_results(alias, device_index, unit_index)`

**State-changing** (emit witness records)
- `lads_call_state_machine_method(alias, device_index, unit_index, method_name)` — validates method_name against server-declared inventory
- `lads_start_program(alias, device_index, unit_index, template_name, properties?, samples?, supervisory_job_id?, supervisory_task_id?)` — validates template_name against ProgramManager templates

**Witness retrieval**
- `lads_list_witness_recent(limit?)`
- `lads_show_witness(sha256_prefix)` — min 4 characters

## Approval-gate demo

```python
lads_call_state_machine_method(alias, 0, 0, "Start")
# -> {invocation: "queued (async)", witness_hash: "..."}

lads_call_state_machine_method(alias, 0, 0, "ArbitraryExec")
# -> {error: "method 'ArbitraryExec' not declared by this state machine",
#     valid_methods: ["Start", "StartProgram", "Stop"]}
```

The rejection happens at the MCP tool boundary — no OPC UA call is fired
for an operation name that the LADS server did not declare. Same pattern
for `lads_start_program(template_name=...)`.

## Witnessed provenance

Every state-changing tool call emits a record like:

```json
{
  "initiator": "mcp-caller",
  "node_alias": "viscometer",
  "server_url": "opc.tcp://localhost:4840",
  "device_browse_name": "myViscometer",
  "unit_browse_name": "ViscometerUnit",
  "operation": "StartProgram",
  "template_name": "Analytical Method A (30rpm)",
  "params": {"Speed": "30", "Duration": "60"},
  "sample_context": [{"ContainerId": "vial-01", "SampleId": "sample-a",
                      "Position": "A1", "CustomData": "phase4-test"}],
  "timestamp_utc": "2026-08-26T00:23:54.333266+00:00",
  "phase": "accepted",
  "sha256_hex": "174b5e1498a5d0cca55ce9291adf95f612161ed590b839258e700e994cba25ae"
}
```

Persisted to `~/.rei-lads/witness/YYYY-MM.jsonl` (env override
`REI_LADS_WITNESS_DIR`). Retrievable via the two `lads_*_witness_*`
tools by SHA-256 prefix.

## Related work

- [`opcua-lads/lads-server-collection`](https://github.com/opcua-lads/lads-server-collection) — LADS reference OPC UA servers (Viscometer / pH-Meter / Balance / Freezer / FT-NIR)
- [`opcua-lads/lads-client-py`](https://github.com/opcua-lads/lads-client-py) — the Python client this bridge wraps
- [`TECTOS-JP/lab-visa-mcp`](https://github.com/TECTOS-JP/lab-visa-mcp) — sibling MCP for PyVISA-controlled instruments
- [`fc0web/rei-lang`](https://github.com/fc0web/rei-lang) — the Rei language project this bridge implements the discipline of
- [Rei-Certified program](https://github.com/fc0web/rei-lang) (draft) — the compliance surface this bridge is a candidate G1 pilot for

## Preview status

- **Phase 1-4 spike complete** — 15 tools, L2 and L3 both green against
  a live LADS viscometer sim
- **Phase 4 fixes that are already in code**: banner silencing at import,
  `Connection.initialized` polling, `QualifiedName` unwrap
- **Phase 5 is not yet implemented** — see [ROADMAP.md](ROADMAP.md)
- **API stability**: not before v1.0.0 — expect breaking changes in 0.x

Full spike history is preserved in [SPIKE-HISTORY.md](SPIKE-HISTORY.md)
for the record.

## License

MIT — see [LICENSE](LICENSE). LADS ecosystem convention.

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md). External contributors will
require a signed CLA once the Rei-project CLA is finalized.