Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
88603eb
SDK: connection 'usb' prefers the pod's USB network adapter (fw 3.9.0+)
wardviaene Oct 13, 2026
95c4665
MCP: 'usb' reaches the full API over the USB network; kind usb_net
wardviaene Oct 13, 2026
975f9de
examples smoke: wait for the DUT rail to settle before reading it
wardviaene Oct 13, 2026
f3db288
examples smoke: import order
wardviaene Oct 13, 2026
3d5ab97
discovery: keep every address a pod answers from; prefer its USB link
wardviaene Oct 13, 2026
1a84ceb
usb:<name> picks one of several pods on USB; plain usb lists them
wardviaene Oct 13, 2026
141d8e6
MCP status: warn about a USB port with only default power
wardviaene Oct 13, 2026
b9ad16f
serial: name the way out when the console has no la-voltage/power
wardviaene Oct 13, 2026
0a0a7a8
serial: recognize the 3.9.0 console's moved line; README compatibilit…
wardviaene Oct 13, 2026
41dc439
usb: fall back to the pod's network address when its USB network is off
wardviaene Oct 13, 2026
4825043
usb: ping before falling back to the pod's LAN address; clearer conso…
wardviaene Oct 13, 2026
e23fff9
embeddedci 2.12.0, embeddedci-mcp 2.12.0, embeddedci-openhtf 2.3.0; p…
wardviaene Oct 13, 2026
64f3a73
serial: drop the console JSON-mode probe and JSON-over-serial code
wardviaene Oct 13, 2026
3a65b31
e2e usb tier: firmware 3.9.0's console has no LA voltage or power
wardviaene Oct 13, 2026
375ca53
docs: two pods on one computer are not tested on hardware yet
wardviaene Oct 13, 2026
9a4349a
Refusal of a CA/proxy change from the LAN says "or over USB"
wardviaene Oct 13, 2026
42802db
Locked-LAN refusal says "needs the cloud or USB"
wardviaene Oct 13, 2026
db35d17
e2e: LA host-clock test fires its events inside the acquisition
wardviaene Oct 13, 2026
189ae5e
usb: find the pod from this computer's USB link before the console
wardviaene Oct 13, 2026
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
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,14 @@ jobs:
run: |
MIN=$(python scripts/min_sdk_version.py packages/embeddedci-mcp/pyproject.toml)
echo "minimum embeddedci: $MIN"
python -m pip install "embeddedci[cloud,discovery,analysis]==$MIN"
# A minimum this repository is about to release is not on PyPI yet: test the in-repo SDK,
# which is that version (the release publishes the SDK before the MCP server).
if ! python -m pip install "embeddedci[cloud,discovery,analysis]==$MIN"; then
LOCAL=$(python -c "import sys; sys.path.insert(0, 'scripts'); import check_release_tag as c; print(c.pyproject_version('embeddedci'))")
if [ "$LOCAL" != "$MIN" ]; then echo "embeddedci $MIN is neither on PyPI nor in this repository ($LOCAL)"; exit 1; fi
echo "embeddedci $MIN is not on PyPI yet: using the in-repo package"
python -m pip install -e "packages/embeddedci[cloud,discovery,analysis]"
fi
python -m pip install -e "packages/embeddedci-mcp[dev]"
python -c "import embeddedci, sys; v = embeddedci.__version__; print('testing against embeddedci', v, embeddedci.__file__); sys.exit(v != '$MIN')"
- name: Run the MCP tests
Expand Down
16 changes: 16 additions & 0 deletions packages/embeddedci-mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog — `embeddedci-mcp`

## 2.12.0

- Needs `embeddedci` 2.12.0 or later (was 2.9): its `usb` connection is what reaches the USB
network adapter the instructions describe.
- Connection `usb` reaches the full API over the pod's USB network adapter on firmware 3.9.0 and
later (through the `embeddedci` SDK of the same release), unless Wi-Fi is configured on the pod;
otherwise it falls back to the USB console as before. `connect`/`status` report the new kind
`usb_net` when the USB network is in use. A serial device path stays the console.
- New connection form `usb:<name>` picks one of several pods plugged into one computer, by name
(`usb:baea06`, `usb:benchpod-baea06`). Plain `usb` with several pods errors and lists them.
`connect`'s `connection` description lists it.
- `status` warns when the USB-C port powering the pod advertises only default USB power (500 mA,
firmware 3.9.0's `status.usb.source`): it may not carry the pod plus a DUT on the internal 5 V rail.
- A `usb` connection that went to the pod's network address (3.9.0+ with USB networking off or
down) reports kind `tcp`, and `status` warns which way it went.

## 2.11.0

- `flash` can flash a build uploaded to embeddedci.com instead of a `file`: new `build` (id or
Expand Down
11 changes: 8 additions & 3 deletions packages/embeddedci-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@ maps to SDK calls, so anything an agent discovers interactively can become a pyt
## Requirements

- Python 3.10+ and [uv](https://docs.astral.sh/uv/) (for `uvx`), or `pip`.
- A BenchPod reachable over the network or the embeddedci.com cloud. (A USB connection works for
status, LA voltage and power only: the STM32 pod's USB console has no JSON mode.)
- A BenchPod reachable over the network, the embeddedci.com cloud, or USB. With firmware 3.9.0
and later, connection `usb` reaches the full API over the pod's USB network adapter (unless
Wi-Fi is configured on the pod, which turns USB networking off); `status` then reports kind
`usb_net`. A serial device path, or `usb` on older firmware, is the USB console: status, LA
voltage and power only, since the STM32 pod's console has no JSON mode. With several pods
plugged into one computer, `usb` errors and lists them by name; pick one with `usb:<name>`
(for example `usb:benchpod-baea06`).
- For `flash`: OpenOCD with the `cmsis_dap_tcp` backend (newer than 0.12.0 — e.g.
`brew install --HEAD open-ocd`, or the xPack build) on the machine running the server. The
firmware `file` is read from that machine too. Flashing a build uploaded to embeddedci.com
Expand Down Expand Up @@ -121,7 +126,7 @@ chat never blocks CI on that pod.

| Flag | Environment | Default | |
| --- | --- | --- | --- |
| `--connection` | `BENCHPOD_CONNECTION` | the CLI's saved connection, else mDNS | host[:port], serial device, `usb`, `discover`, `saved`, or `embeddedci:<device>`; unset = `benchpod set-connection`'s saved one, else find the one pod on the LAN |
| `--connection` | `BENCHPOD_CONNECTION` | the CLI's saved connection, else mDNS | host[:port], serial device, `usb`, `usb:<name>`, `discover`, `saved`, or `embeddedci:<device>`; unset = `benchpod set-connection`'s saved one, else find the one pod on the LAN |
| `--la-voltage` | `BENCHPOD_LA_VOLTAGE` | — | LA I/O voltage (1.8 or 3.3) applied on connect |
| — | `BENCHPOD_API_KEY` | — | cloud pods and the waveform library (without it, cloud tools use the `benchpod login` session) |
| — | `BENCHPOD_API_BASE` | `https://www.embeddedci.com` | another embeddedci server |
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.11.0"
version = "2.12.0"
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.9,<3",
"embeddedci[cloud,discovery,analysis]>=2.12.0,<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.11.0",
"version": "2.12.0",
"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.11.0",
"version": "2.12.0",
"runtimeHint": "uvx",
"transport": {
"type": "stdio"
Expand Down
2 changes: 1 addition & 1 deletion packages/embeddedci-mcp/src/embeddedci_mcp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def build_parser() -> argparse.ArgumentParser:
help="'stdio' (default, launched by an MCP client) or 'http' (streamable HTTP).")
parser.add_argument("--connection", default=None,
help="Default connection for the connect tool: host[:port], a serial device, "
"'usb', or 'embeddedci:<device>'. Falls back to BENCHPOD_CONNECTION.")
"'usb', 'usb:<name>', or 'embeddedci:<device>'. Falls back to BENCHPOD_CONNECTION.")
parser.add_argument("--la-voltage", type=float, choices=[1.8, 3.3], default=None,
help="LA I/O voltage applied on connect. Falls back to BENCHPOD_LA_VOLTAGE.")
parser.add_argument("--lease-wait", type=float, default=30.0,
Expand Down
2 changes: 1 addition & 1 deletion packages/embeddedci-mcp/src/embeddedci_mcp/guide.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
Drive an EmbeddedCI BenchPod: a hardware-in-the-loop tester wired to a real target board (the DUT).

Start every session with:
1. connect — a host[:port] or 'embeddedci:<device>'. Omit it to use the server default (--connection, else BENCHPOD_CONNECTION, else the connection saved with `benchpod set-connection`); with none configured, connect finds the one pod on the LAN over mDNS, and if it finds none or several its error lists them and what to pass instead (a host, 'usb', or a cloud pod from cloud_list_devices). A USB connection ('usb' or a serial device path) only supports status, LA voltage and power on an STM32 pod; everything else needs the network or cloud.
1. connect — a host[:port] or 'embeddedci:<device>'. Omit it to use the server default (--connection, else BENCHPOD_CONNECTION, else the connection saved with `benchpod set-connection`); with none configured, connect finds the one pod on the LAN over mDNS, and if it finds none or several its error lists them and what to pass instead (a host, 'usb', or a cloud pod from cloud_list_devices). With firmware 3.9.0 and later, 'usb' reaches the full API over the pod's USB network adapter (unless Wi-Fi is configured on the pod, which turns USB networking off; status then reports kind 'usb_net' when it worked). With several pods plugged into one computer, 'usb' errors and lists them by name; pick one with 'usb:<name>' (for example 'usb:benchpod-baea06'). A serial device path, or 'usb' that fell back to the console (kind 'serial'), only supports status, LA voltage and power on an STM32 pod; everything else needs the network, the USB network or cloud.
2. set_la_voltage (1.8 or 3.3, the DUT's I/O voltage) unless status already reports one. The pod refuses flash, UART, LA capture, pull resistors and I2C-sensor emulation until it is set.
3. status shows firmware, capabilities, the LA voltage and open sessions.

Expand Down
4 changes: 3 additions & 1 deletion packages/embeddedci-mcp/src/embeddedci_mcp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ class SessionInfo(BaseModel):
class StatusResult(BaseModel):
connected: bool
connection: Optional[str] = None
kind: Optional[str] = Field(None, description="tcp, serial, discover or embeddedci (cloud).")
kind: Optional[str] = Field(None, description=(
"tcp, serial (USB console), usb_net ('usb' reached the pod's USB network adapter), "
"discover or embeddedci (cloud)."))
leased: bool = False
la_voltage: Optional[float] = Field(None, description="Selected LA bank voltage; null = not set.")
capabilities: Optional[CapabilitiesInfo] = None
Expand Down
14 changes: 13 additions & 1 deletion packages/embeddedci-mcp/src/embeddedci_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,16 @@ def _status() -> m.StatusResult:
if isinstance(firmware, dict) and firmware.get("lan_policy") == "locked" and SESSION.kind == "tcp":
warnings.append("the pod's LAN policy is locked: configuration and firmware commands need "
"the cloud ('embeddedci:<device>') or the USB console")
fallback = getattr(pod.transport, "usb_lan_fallback", "")
if isinstance(fallback, str) and fallback:
warnings.append(f"connection 'usb' went to the pod's network address because {fallback}; the "
"pod's external LAN policy applies there (`benchpod clear-wifi` turns USB "
"networking back on)")
usb = firmware.get("usb") if isinstance(firmware, dict) else None
if isinstance(usb, dict) and usb.get("source") == "default":
warnings.append("the USB port powering the pod offers only default USB power (500 mA), which may "
"not carry the pod plus a DUT on the internal 5 V rail: use a USB-C port or "
"charger with 1.5 A or more, or power the DUT externally")
try:
wiring_source: Optional[str] = pod.wiring.source
except (BenchPodError, ValueError, OSError):
Expand All @@ -332,7 +342,9 @@ def _status() -> m.StatusResult:
@mcp.tool(annotations=_ann("Connect to a BenchPod", idempotent=True, cloud=True))
async def connect(
connection: Annotated[Optional[str], Field(description=(
"host[:port] (TCP, default port 8080), a serial device path, 'usb' (auto-detect), "
"host[:port] (TCP, default port 8080), a serial device path (the USB console only), 'usb' "
"(auto-detect: the pod's USB network adapter with the full API on firmware 3.9.0+, else "
"its console), 'usb:<name>' (one of several pods on USB, by name: 'usb:benchpod-baea06'), "
"'discover' (mDNS) or 'embeddedci:<device-name>' (cloud). Omit to use the server default "
"(--connection, else BENCHPOD_CONNECTION, else the connection saved with `benchpod "
"set-connection`), else to find the one pod on the LAN over mDNS."))] = None,
Expand Down
20 changes: 17 additions & 3 deletions packages/embeddedci-mcp/src/embeddedci_mcp/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import os
import threading
import time
from typing import Callable, Optional, Tuple
from typing import Any, Callable, Optional, Tuple

from embeddedci.benchpod import (
BenchPod,
Expand Down Expand Up @@ -51,13 +51,26 @@ def connection_kind(connection: str) -> str:
s = connection.strip()
if s.lower().startswith(CLOUD_PREFIX):
return "embeddedci"
if s.lower() in ("usb", "serial") or _is_device_path(s):
if s.lower() in ("usb", "serial") or s.lower().startswith("usb:") or _is_device_path(s):
return "serial"
if s.lower() in DISCOVER_KEYWORDS:
return "discover"
return "tcp"


def transport_kind(connection: str, pod: Any) -> str:
""":func:`connection_kind`, except ``"usb_net"`` when a USB connection (``usb`` or
``usb:<name>``) resolved to the pod's USB network adapter (firmware 3.9.0+) rather than its
console."""
kind = connection_kind(connection)
transport = getattr(pod, "transport", None)
if kind == "serial" and (getattr(transport, "usb_net", False) is True or getattr(transport, "usb_console", "")):
return "usb_net"
if kind == "serial" and getattr(transport, "usb_lan_fallback", ""):
return "tcp" # USB networking off or down: the pod's Ethernet or Wi-Fi address
return kind


def cloud_user_token(connection: str) -> Optional[Callable[[], str]]:
"""The `benchpod login` session, without BENCHPOD_API_KEY and outside GitHub Actions (which uses
OIDC). For a cloud connection the SDK exchanges it for a device session token; for a LAN or USB
Expand Down Expand Up @@ -166,7 +179,7 @@ def connect(self, connection: Optional[str] = None, *, la_voltage: Optional[floa
la = self.default_la_voltage if la_voltage is None else la_voltage
wait = self.lease_wait if lease_wait is None else lease_wait
self._pod = self._open(conn, la, wait)
self.connection, self.kind = conn, connection_kind(conn)
self.connection, self.kind = conn, transport_kind(conn, self._pod)
self._reconnect = (conn, la, wait)
self._touch()
return self._pod
Expand All @@ -179,6 +192,7 @@ def require(self) -> BenchPod:
raise NotConnectedError("not connected to a BenchPod — call the `connect` tool first")
conn, la, wait = self._reconnect # type: ignore[misc]
self._pod = self._open(conn, la, wait)
self.kind = transport_kind(conn, self._pod)
self._idle_closed = False
self._touch()
return self._pod
Expand Down
3 changes: 2 additions & 1 deletion packages/embeddedci-mcp/tests/test_e2e_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ def connected(benchpod_connection):

def test_status_and_typed_state(connected):
assert connected["capabilities"]["board"]
assert connected["warnings"] == []
# A bench pod on a computer's USB port is told about its 500 mA port; nothing else may warn.
assert [w for w in connected["warnings"] if "default USB power" not in w] == []
assert "internal" in call("power_status")
channels = call("pull_status")["channels"]
assert [c["direction"] for c in channels] == ["up"] * 6 + ["down"] * 2
Expand Down
8 changes: 8 additions & 0 deletions packages/embeddedci-mcp/tests/test_refusals.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,14 @@ def test_status_warns_about_a_cloud_lease_and_a_locked_lan(connected):
assert "LAN policy is locked" in warnings


def test_status_warns_about_a_weak_usb_port(connected):
real = connected.status
connected.status = lambda: dict(real(), usb={"net": "on", "link": True, "source": "default", "source_ma": 500})
assert any("default USB power (500 mA)" in w for w in call("status")["warnings"])
connected.status = lambda: dict(real(), usb={"net": "on", "link": True, "source": "3.0A", "source_ma": 3000})
assert not any("USB power" in w for w in call("status")["warnings"])


def test_capabilities_report_the_policy_flags(fake_open, fake_transport):
real = fake_transport.status
fake_transport.status = lambda: dict(real(), lan_policy="open", ota_sig=True,
Expand Down
38 changes: 38 additions & 0 deletions packages/embeddedci-mcp/tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,44 @@ def test_connect_without_la_voltage_warns(fake_open):
assert any("set_la_voltage" in w for w in result["warnings"])


def test_connect_usb_over_the_usb_network_reports_usb_net(fake_open, fake_transport, monkeypatch):
# 'usb' that resolved to the pod's USB network adapter (firmware 3.9.0+): the SDK hands back a
# TCP transport whose usb_console names the console it found.
monkeypatch.setattr(fake_transport, "usb_console", "/dev/cu.usbmodemPOD", raising=False)
assert call("connect", connection="usb", la_voltage=3.3)["kind"] == "usb_net"
assert call("status")["kind"] == "usb_net"
# Without usb_console (the console, or a LAN pod) the kind is the connection string's.
from embeddedci_mcp.session import transport_kind
console = type("Console", (), {"transport": object()})()
assert transport_kind("/dev/ttyACM0", console) == "serial"
assert transport_kind("usb", console) == "serial"
assert transport_kind("usb:benchpod-baea06", console) == "serial"
assert transport_kind("192.168.1.50", console) == "tcp"


def test_connect_usb_over_the_local_usb_link_reports_usb_net(fake_open, fake_transport, monkeypatch):
# 'usb' that found the pod from this computer's end of its USB link: no console was opened.
monkeypatch.setattr(fake_transport, "usb_net", True, raising=False)
assert call("connect", connection="usb", la_voltage=3.3)["kind"] == "usb_net"


def test_connect_usb_name_reports_usb_net(fake_open, fake_transport, monkeypatch):
# usb:<name> picks one of several pods on USB; it upgrades to the USB network like 'usb'.
monkeypatch.setattr(fake_transport, "usb_console", "/dev/cu.usbmodemPOD", raising=False)
assert call("connect", connection="usb:benchpod-baea06", la_voltage=3.3)["kind"] == "usb_net"
from embeddedci_mcp.session import connection_kind
assert connection_kind("USB:baea06") == "serial"


def test_connect_usb_falling_back_to_the_lan_reports_tcp_and_warns(fake_open, fake_transport, monkeypatch):
# 'usb' on firmware 3.9.0+ with USB networking off: the SDK went to the pod's network address.
monkeypatch.setattr(fake_transport, "usb_lan_fallback",
"the pod's USB network is off (Wi-Fi is configured) or down", raising=False)
assert call("connect", connection="usb", la_voltage=3.3)["kind"] == "tcp"
warnings = " | ".join(call("status")["warnings"])
assert "network address" in warnings and "clear-wifi" in warnings


def test_connect_rolls_back_when_the_pod_does_not_answer(fake_open, fake_transport, monkeypatch):
from embeddedci.benchpod.errors import TransportError

Expand Down
2 changes: 1 addition & 1 deletion packages/embeddedci-mcp/tests/tools_surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -808,7 +808,7 @@
}
],
"default": null,
"description": "host[:port] (TCP, default port 8080), a serial device path, 'usb' (auto-detect), 'discover' (mDNS) or 'embeddedci:<device-name>' (cloud). Omit to use the server default (--connection, else BENCHPOD_CONNECTION, else the connection saved with `benchpod set-connection`), else to find the one pod on the LAN over mDNS.",
"description": "host[:port] (TCP, default port 8080), a serial device path (the USB console only), 'usb' (auto-detect: the pod's USB network adapter with the full API on firmware 3.9.0+, else its console), 'usb:<name>' (one of several pods on USB, by name: 'usb:benchpod-baea06'), 'discover' (mDNS) or 'embeddedci:<device-name>' (cloud). Omit to use the server default (--connection, else BENCHPOD_CONNECTION, else the connection saved with `benchpod set-connection`), else to find the one pod on the LAN over mDNS.",
"title": "Connection"
},
"la_voltage": {
Expand Down
7 changes: 7 additions & 0 deletions packages/embeddedci-openhtf/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog — `embeddedci-openhtf`

## 2.3.0

- Needs `embeddedci` 2.12.0 or later (was 2.4), the SDK whose `usb` connection reaches the USB
network adapter these docs describe.
- Docs: connection `usb` reaches the pod's USB network adapter (full API) on firmware 3.9.0 and
later, through the `embeddedci` SDK of the same release; a serial device path stays the console.

## 2.2.0

- Persistent plugs (`benchpod_plug(..., persistent=True)`) are pooled by their connection
Expand Down
Loading