Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/embeddedci-mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog — `embeddedci-mcp`

## 2.13.2

- Requires embeddedci 2.13.2.
- New tool `lan_tls`: the pod's LAN API over TLS (firmware 3.9.3+), HTTPS-only and regenerate.
`connect` to a network address uses TLS when the pod has it and pins its certificate (from the
SDK).

## 2.13.1

- Requires embeddedci 2.13.1: `Capabilities.scope` is False on the digital-only board, so `status`
Expand Down
7 changes: 7 additions & 0 deletions packages/embeddedci-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ lists the pods it heard (name and address) and what to pass instead: `host[:port
`claude mcp add benchpod -- uvx embeddedci-mcp` is enough for a single pod on your network: just
ask the agent to connect.

**TLS.** A network address uses TLS on port 8443 when the pod has it (firmware 3.9.3+) and pins
the pod's certificate on first use, in the same `known_pods.json` as `benchpod-cli`; older pods
stay on plaintext 8080. `BENCHPOD_TLS=on|off` and `BENCHPOD_TLS_CA` work as in the SDK. A changed
certificate stops `connect` with both fingerprints: after a deliberate change, the user runs
`benchpod lan-tls trust`. The `lan_tls` tool shows the certificate and switches HTTPS-only.

### Claude Desktop / Cursor

`claude_desktop_config.json` (Claude Desktop) or `.cursor/mcp.json` (Cursor):
Expand Down Expand Up @@ -261,6 +267,7 @@ the pod's second UART, so `uart_open` keeps the console. Needs gateware v48 (`gp
| --- | --- | --- |
| `set_pull` | Engage or release the bias resistors: LA1-LA6 pull up, LA7/LA8 pull down. | **`las`**, **`enabled`** |
| `pull_status` | State, direction and value of the bias resistor on LA1-LA8. | none |
| `lan_tls` | The pod's LAN API over TLS (firmware 3.9.3+): port 8443, the HTTPS-only setting and the certificate's sha256 (what clients pin). Turning HTTPS-only off and regenerating need a TLS, USB or cloud connection. | `https_only`, `regenerate` |
| `usb_network` | Read the pod's USB network setting (firmware 3.9.0+), or turn the adapter off over the USB link. Only the device changes it; turning it back on is `benchpod usb-network on` on the USB console. | `set=off` |

### GPIO on the LA pins
Expand Down
4 changes: 2 additions & 2 deletions packages/embeddedci-mcp/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "embeddedci-mcp"
version = "2.13.1"
version = "2.13.2"
description = "MCP server that lets AI agents drive an EmbeddedCI BenchPod hardware-in-the-loop bench"
readme = "README.md"
requires-python = ">=3.10"
Expand All @@ -26,7 +26,7 @@ classifiers = [
dependencies = [
# cloud: the embeddedci:<device> destination; discovery: connect("discover") over mDNS;
# analysis: dominant frequency in capture summaries.
"embeddedci[cloud,discovery,analysis]>=2.13.1,<3",
"embeddedci[cloud,discovery,analysis]>=2.13.2,<3",
"mcp>=1.12,<2",
"anyio>=4",
"pydantic>=2.7",
Expand Down
4 changes: 2 additions & 2 deletions packages/embeddedci-mcp/server.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "io.github.embeddedci-com/embeddedci-mcp",
"title": "EmbeddedCI BenchPod",
"description": "Drive a BenchPod hardware-in-the-loop bench: power, flash, UART, I2C, CAN, analog and logic.",
"version": "2.13.1",
"version": "2.13.2",
"websiteUrl": "https://www.embeddedci.com/docs/benchpod-mcp",
"repository": {
"url": "https://github.com/embeddedci-com/embeddedci-python",
Expand All @@ -15,7 +15,7 @@
"registryType": "pypi",
"registryBaseUrl": "https://pypi.org",
"identifier": "embeddedci-mcp",
"version": "2.13.1",
"version": "2.13.2",
"runtimeHint": "uvx",
"transport": {
"type": "stdio"
Expand Down
15 changes: 15 additions & 0 deletions packages/embeddedci-mcp/src/embeddedci_mcp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,21 @@ class StepResult(BaseModel):
status: str = "started"


class LanTlsResult(BaseModel):
"""The pod's LAN API over TLS (firmware 3.9.3+)."""
port: int = Field(description="The TLS port (8443).")
https_only: bool = Field(description="True: plaintext 8080 is refused on Ethernet and Wi-Fi (the USB link keeps it).")
sessions: int = Field(description="TLS connections open now (at most 2).")
origin: str = Field("", description='"generated" (the pod made it) or "user" (installed).')
key: str = Field("", description='The key type, e.g. "EC P-256".')
subject: str = Field("", description="The certificate's subject.")
san: List[str] = Field(default_factory=list, description="The certificate's DNS names.")
not_after: str = Field("", description="Valid until (YYYY-MM-DD).")
sha256: str = Field("", description="SHA-256 of the certificate: the value clients pin.")
connected_over_tls: bool = Field(False, description="Whether this MCP server's connection to the pod uses TLS.")
error: str = Field("", description="Why there is no certificate in use.")


class UsbNetworkResult(BaseModel):
"""The pod's USB network setting (firmware 3.9.0+)."""
setting: str = Field(description='"on" or "off": whether the pod\'s USB port is also a network adapter.')
Expand Down
29 changes: 29 additions & 0 deletions packages/embeddedci-mcp/src/embeddedci_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1097,6 +1097,35 @@ def op() -> m.UsbNetworkResult:
return await _call(op)


@mcp.tool(annotations=_ann("LAN TLS", destructive=True))
async def lan_tls(
https_only: Annotated[Optional[bool], Field(description=(
"Omit to read. True: the pod refuses plaintext port 8080 on Ethernet and Wi-Fi, so only TLS "
"(8443) crosses the LAN; its USB link keeps 8080. False turns it back off, which the pod "
"only takes over TLS, USB or the cloud."))] = None,
regenerate: Annotated[bool, Field(description=(
"True: the pod makes a new key and self-signed certificate. Clients that pinned the old one "
"must trust the new one (`benchpod lan-tls trust`); this server's pin follows."))] = False,
) -> m.LanTlsResult:
"""The pod's LAN API over TLS (firmware 3.9.3+): port 8443, HTTPS-only, and the certificate
clients pin (sha256). Connections to a network address use TLS by default when the pod has it.
Installing your own certificate: `benchpod lan-tls set-cert` or the SDK's set_lan_tls_cert."""
def op() -> m.LanTlsResult:
pod = SESSION.require()
if regenerate:
info = pod.regenerate_lan_tls()
elif https_only is not None:
info = pod.set_lan_tls_https_only(https_only)
else:
info = pod.lan_tls()
return m.LanTlsResult(port=info.port, https_only=info.https_only, sessions=info.sessions,
origin=info.origin, key=info.key, subject=info.subject, san=list(info.san),
not_after=info.not_after, sha256=info.sha256, error=info.error,
connected_over_tls=bool(getattr(pod.transport, "last_tls", False)))

return await _call(op)


@mcp.tool(annotations=_ann("Pull resistor status", read_only=True))
async def pull_status() -> m.PullStatusResult:
"""State, direction and value of the bias resistor on LA1-LA8."""
Expand Down
18 changes: 17 additions & 1 deletion packages/embeddedci-mcp/tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"enable_i2c_sensor", "set_i2c_sensor", "disable_i2c_sensor", "i2c_sensor_status",
"i2c_sensor_regs", "i2c_sensor_capture", "i2c_sensor_types",
"enable_gps", "set_gps", "disable_gps", "gps_status",
"set_pull", "pull_status", "usb_network",
"set_pull", "pull_status", "usb_network", "lan_tls",
"analog_path", "dac_output", "current_out", "adc_read", "calibration", "calibrate",
"capture_adc", "capture_la", "capture_correlated", "decode_la", "la_timing",
"generate", "dac_stop", "replay", "list_waveforms", "replay_waveform",
Expand Down Expand Up @@ -612,6 +612,22 @@ def test_la_step_and_command(connected):
call("command", request={"samples": 1})


def test_lan_tls_reads_and_changes(fake_open, fake_transport, monkeypatch):
from embeddedci.benchpod import LanTls
call("connect", connection="usb", la_voltage=3.3)
pod = SESSION.require()
seen = []
info = LanTls(port=8443, https_only=False, sessions=0, sha256="ab" * 32, san=("benchpod-x",))
monkeypatch.setattr(pod, "lan_tls", lambda: seen.append("read") or info, raising=False)
monkeypatch.setattr(pod, "set_lan_tls_https_only", lambda on: seen.append(on) or info, raising=False)
monkeypatch.setattr(pod, "regenerate_lan_tls", lambda: seen.append("regen") or info, raising=False)
res = call("lan_tls")
assert res["port"] == 8443 and res["sha256"] == "ab" * 32 and res["san"] == ["benchpod-x"]
call("lan_tls", https_only=True)
call("lan_tls", regenerate=True)
assert seen == ["read", True, "regen"]


def test_usb_network_reads_and_turns_off(fake_open, fake_transport, monkeypatch):
call("connect", connection="usb", la_voltage=3.3)
pod = SESSION.require()
Expand Down
34 changes: 34 additions & 0 deletions packages/embeddedci-mcp/tests/tools_surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -2572,6 +2572,40 @@
"type": "object"
}
},
"lan_tls": {
"annotations": {
"destructiveHint": true,
"idempotentHint": false,
"openWorldHint": false,
"readOnlyHint": false,
"title": "LAN TLS"
},
"input": {
"properties": {
"https_only": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"default": null,
"description": "Omit to read. True: the pod refuses plaintext port 8080 on Ethernet and Wi-Fi, so only TLS (8443) crosses the LAN; its USB link keeps 8080. False turns it back off, which the pod only takes over TLS, USB or the cloud.",
"title": "Https Only"
},
"regenerate": {
"default": false,
"description": "True: the pod makes a new key and self-signed certificate. Clients that pinned the old one must trust the new one (`benchpod lan-tls trust`); this server's pin follows.",
"title": "Regenerate",
"type": "boolean"
}
},
"title": "lan_tlsArguments",
"type": "object"
}
},
"list_waveforms": {
"annotations": {
"idempotentHint": false,
Expand Down
12 changes: 12 additions & 0 deletions packages/embeddedci/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog — `embeddedci`

## 2.13.2

- The LAN API over TLS (pod firmware 3.9.3+): a network address on the default port connects to
8443 with TLS when the pod has it and falls back to plaintext 8080 only when 8443 refuses and
nothing is pinned. The pod's certificate is pinned on first use in `known_pods.json`, shared with
`benchpod-cli`; a different one raises `TlsPinMismatchError`. `BENCHPOD_TLS` (auto/on/off) and
`BENCHPOD_TLS_CA` (verify against a CA instead). New: `BenchPod.lan_tls()`,
`set_lan_tls_https_only()`, `regenerate_lan_tls()`, `set_lan_tls_cert()`, `LanTls`,
`trust_lan_tls()`, `forget_lan_tls()`, `TcpTransport.last_tls`, `TcpTransport.command_sequence()`.
The transport keeps the last TLS session per pod, so later connections resume in ~35 ms instead
of the pod's ~0.46 s full handshake.

## 2.13.1

- `Capabilities.scope` is False on the digital-only board. Current firmware lists `signal` on every
Expand Down
37 changes: 36 additions & 1 deletion packages/embeddedci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,36 @@ power moved to the network too; see the compatibility table above.)
| `replay()` / client-side `replay_waveform()` (streams the waveform to the pod) | yes | yes | no | yes |
| Device lease | — | — | — | yes |

### TLS on the LAN

Pods with firmware 3.9.3 or later also serve the API on port 8443 with TLS. For a network address
on the default port (`192.168.1.5`, `:8080`, a pod name) the SDK connects to 8443 first and falls
back to plaintext 8080 only when the pod has no TLS port and nothing is pinned for it, so older
pods keep working. `:8443` means TLS only; any other port and the USB network adapter (`usb`, a
physical link) stay plaintext. `BENCHPOD_TLS=on|off` overrides (`auto` is the default).
`bp.transport.last_tls` says which one the last connection used.

The pod's certificate is its own, so the SDK pins its SHA-256 the first time (trust on first use)
in `known_pods.json` next to `benchpod-cli`'s config; the CLI reads and writes the same file. A
different certificate later raises `TlsPinMismatchError` (`addr`, `pinned`, `got`). After a
deliberate change elsewhere, `trust_lan_tls(host, sha256=None)` pins the certificate the pod
presents now (`sha256` only accepts that one) and `forget_lan_tls(host)` drops the pin. With
`BENCHPOD_TLS_CA=ca.pem` the certificate and host name are verified against that CA instead (for a
certificate you installed, signed by your own CA).

```python
bp = BenchPod("192.168.1.220")
info = bp.lan_tls() # LanTls: port, https_only, sessions, subject, san, sha256, ...
bp.set_lan_tls_https_only(True) # plaintext 8080 off on Ethernet and Wi-Fi (USB keeps it)
bp.set_lan_tls_cert(open("pod.pem").read(), open("pod.key").read())
bp.regenerate_lan_tls()
```

`set_lan_tls_https_only(False)`, `regenerate_lan_tls()` and `set_lan_tls_cert()` need a TLS, USB or
cloud connection; over plaintext Ethernet or Wi-Fi the pod refuses (`PodLockedError`).
`set_lan_tls_cert` needs a network connection (it uploads the bundle); after it and after
`regenerate_lan_tls()` the SDK pins the new certificate for the address it used.

### Environment variables

| Variable | Used when | Purpose |
Expand All @@ -266,6 +296,8 @@ power moved to the network too; see the compatibility table above.)
| `BENCHPOD_WIRING` | no `wiring` argument / `--benchpod-wiring` / `benchpod_wiring` fixture | wiring profile file (`.json`/`.toml`), see [Wiring profile](#wiring-profile) |
| `BENCHPOD_WIRING_SOURCE` | no `wiring_source` argument | `local` never asks embeddedci.com for the pod's stored wiring profile; `auto` (default) does, see [Wiring profile](#wiring-profile) |
| `BENCHPOD_LIFT_DAC_LIMITS` | pytest sessions | `1` clears the pod's DAC output limits for the session and restores the same limits at the end, even when tests fail (only with the output stage switched off) |
| `BENCHPOD_TLS` | network connections | `auto` (default), `on` or `off`: TLS to the pod's LAN API on 8443, see [TLS on the LAN](#tls-on-the-lan) |
| `BENCHPOD_TLS_CA` | network connections over TLS | PEM file of CA certificates: verify the pod's certificate and host name instead of pinning it |
| `BENCHPOD_STALL_TIMEOUT` | flashing | seconds with no SWD traffic before a flash attempt is aborted and retried (default 60) |

## API conventions and stability
Expand Down Expand Up @@ -1501,6 +1533,7 @@ Invalid arguments raise `ValueError` before anything is sent. Everything else ra
| `TransportError` | the pod or tunnel could not be reached / talked to | |
| `TransportTimeout` (a `TransportError` and a `TimeoutError`) | the pod or tunnel did not answer in time | |
| `ConnectionClosedError` (a `TransportError`) | the connection ended or reset before the reply | |
| `TlsPinMismatchError` (a `TransportError`) | the pod's TLS certificate is not the one pinned for it (see [TLS on the LAN](#tls-on-the-lan)) | `addr`, `pinned`, `got` |
| `FirmwareError` | the pod replied with an error (e.g. "la voltage not set"); a fix the firmware spells as a raw protocol command is rewritten into the SDK call in the message, and `firmware_message` keeps the pod's own text | `firmware_message`, `cmd` |
| `FlashError` | OpenOCD failed, is missing, or lacks the TCP backend | |
| `TargetUnreachableError` (a `FlashError`) | the probe worked but no target answered on SWD | |
Expand Down Expand Up @@ -1545,7 +1578,7 @@ lease_wait=600.0, lease_ttl=120, wiring=None)`. A context manager; `close()` is

| Area | Methods and properties |
|---|---|
| Connection and device | `ping()`, `status()`, `capabilities`, `refresh_capabilities()` (drop the cached capabilities and read them again), `close()`, `leased` (true while this client holds the cloud lease), `wiring`, `save_wiring(wiring=None)` (store a cloud device's profile on embeddedci.com), `signal(name)`, `fpga_image(image)`, `usb_cc()`, `usb_network(on=None)` (the USB network setting; `False` turns the adapter off over the USB link) |
| Connection and device | `ping()`, `status()`, `capabilities`, `refresh_capabilities()` (drop the cached capabilities and read them again), `close()`, `leased` (true while this client holds the cloud lease), `wiring`, `save_wiring(wiring=None)` (store a cloud device's profile on embeddedci.com), `signal(name)`, `fpga_image(image)`, `usb_cc()`, `usb_network(on=None)` (the USB network setting; `False` turns the adapter off over the USB link), `lan_tls()`, `set_lan_tls_https_only(on)`, `regenerate_lan_tls()`, `set_lan_tls_cert(cert_pem, key_pem)` (the LAN API over TLS, see [TLS on the LAN](#tls-on-the-lan)) |
| LA voltage | `set_la_voltage(volts)`, `get_la_voltage()` |
| Power and reset | `power_on(efuse=None, *, delay=None)`, `power_off(efuse=None, *, delay=None)`, `target_power(efuse=None, *, on, delay=None)`, `target_status()`, `power_status()`, `measure_power(duration, *, efuse=None, rate_hz=500.0, keep_samples=0)`, `power_profile(*, efuse=None, rate_hz=500.0, keep_samples=4096, max_duration=60.0)`, `reset_target(*, pulse=0.1)`, `set_reset(asserted)`, `reset_state()` |
| Flashing | `flash(...)`, `spi_flash(image, addr=0, *, erase=True, verify=True, hold_reset=False, sck=, mosi=, miso=, cs=)`, `open_spi(...)`, `open_motor_emulator(...)` |
Expand Down Expand Up @@ -1577,6 +1610,7 @@ few built from other shapes); `to_dict()` goes the other way where a type is sen
| `PowerProfile` | `measure_power`, `PowerProfileSession.result` | see [Power profiles](#power-profiles) |
| `PowerProfileSession` | `power_profile` (a context manager) | `start()`, `stop()`, `status()`, `result` |
| `ResetState` | `reset_target`, `set_reset`, `reset_state` | `asserted`, `supported` |
| `LanTls` | `lan_tls`, `set_lan_tls_https_only`, `regenerate_lan_tls`, `set_lan_tls_cert` | `port`, `https_only`, `sessions`, `origin` (`generated`/`user`), `key`, `subject`, `san`, `not_after`, `sha256` (what clients pin), `error` (no certificate) |
| `UsbCcStatus` | `usb_cc` | `orientation`, `advertised`, `advertised_current` (A), `cc1_voltage`, `cc2_voltage` (V) |
| `PullState` | `pull_state`, `set_pull` | `la`, `enabled`, `direction`, `ohms`, `available` |
| `LaPinState` | `la_pins`, `configure_gpio` | `la`, `function`, `gpio`, `level`, `pull`, `pull_ohms`, `pull_on`, `in_use` |
Expand Down Expand Up @@ -1677,6 +1711,7 @@ timeout=60.0, lease_id=None)`. Errors raise `ServerApiError` (`status`), and an
|---|---|
| `parse_connection(raw)` | parse a connection string into a `ConnSpec` (raises `ConnectionConfigError`) |
| `resolve_connection(connection=None)` | the same, falling back to `BENCHPOD_CONNECTION` |
| `trust_lan_tls(host, port=8080, *, sha256=None)`, `forget_lan_tls(host, port=8080)` | pin the TLS certificate the pod presents now (returns its SHA-256), or drop the pin (see [TLS on the LAN](#tls-on-the-lan)) |
| `make_build_reporter(*, api_base=None, audience=None, target="", name="")` | a `BuildReporter` inside GitHub Actions, else a `NoopBuildReporter` |
| `build_panel_curve(voc_code, sharpness=4.0, points=256)`, `build_linear_curve(max_code, rising=True, points=256)`, `build_constant_curve(value_code, points=256)` | control-loop curves (see [In-fabric DAC control loop](#in-fabric-dac-control-loop)) |
| `curve_output_at(curve, input_code)`, `input_percent_to_code(percent)` | evaluate a curve the way the gateware does; an input percentage as a code |
Expand Down
2 changes: 1 addition & 1 deletion packages/embeddedci/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "embeddedci"
version = "2.13.1"
version = "2.13.2"
description = "BenchPod SDK and pytest plugin for EmbeddedCI hardware-in-the-loop testing"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
8 changes: 7 additions & 1 deletion packages/embeddedci/src/embeddedci/benchpod/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ def test_boots(benchpod_target, firmware): # fixtures from the pytest plugin
PodLockedError,
PullConflictError,
TargetUnreachableError,
TlsPinMismatchError,
TransportError,
TransportTimeout,
TriggerTimeout,
Expand All @@ -109,7 +110,8 @@ def test_boots(benchpod_target, firmware): # fixtures from the pytest plugin
from .lowlevel import LowLevel
from .power import PowerProfile, PowerProfileSession
from .replay import DacHandle, Fault, ReplayHandle, Segment
from .results import Capture, CorrelatedCapture, LaCapture, Trigger
from .results import Capture, CorrelatedCapture, LaCapture, LanTls, Trigger
from .transport.tls import delete_pin as forget_lan_tls, trust_lan_tls
from .server_api import ServerApi, ServerApiError, ServerPermissionDeniedError
from .state import (
AdcReading,
Expand Down Expand Up @@ -190,6 +192,10 @@ def test_boots(benchpod_target, firmware): # fixtures from the pytest plugin
"dsp",
"Capabilities",
"Capture",
"LanTls",
"TlsPinMismatchError",
"trust_lan_tls",
"forget_lan_tls",
"LaCapture",
"CorrelatedCapture",
"Trigger",
Expand Down
Loading