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
9 changes: 9 additions & 0 deletions packages/embeddedci-mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog — `embeddedci-mcp`

## Unreleased

- `connect` without a connection, `--connection` or `BENCHPOD_CONNECTION` now looks for the pod
on the LAN over mDNS (as `discover` does) instead of failing with "no connection given", so
`BENCHPOD_CONNECTION` is optional for a single pod on your network. When discovery finds no pod
or several, the error lists the pods it heard (name and address) and what to pass instead:
`host[:port]`, `usb`, or `embeddedci:<device>` (from `cloud_list_devices`).
- The `connect` description, the `status` hint and the server instructions say so.

## 2.9.0

- Requires `embeddedci` 2.9.0 (pin raised).
Expand Down
12 changes: 10 additions & 2 deletions packages/embeddedci-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ claude mcp add benchpod \
`BENCHPOD_LA_VOLTAGE` is the board's I/O voltage, configured once here — use `1.8` for a 1V8
board. Without it the agent is told to call `set_la_voltage` before touching the LA bank.

Both variables are optional. With no connection configured (no `connection` argument, no
`--connection`, no `BENCHPOD_CONNECTION`), `connect` looks for a BenchPod on the LAN over mDNS
(like `discover`) and uses it when it finds exactly one. When it finds none or several, the error
lists the pods it heard (name and address) and what to pass instead: `host[:port]`, `usb`, or
`embeddedci:<device>` for a cloud pod (`cloud_list_devices` lists those). So
`claude mcp add benchpod -- uvx embeddedci-mcp` is enough for a single pod on your network: just
ask the agent to connect.

### Claude Desktop / Cursor

`claude_desktop_config.json` (Claude Desktop) or `.cursor/mcp.json` (Cursor):
Expand Down Expand Up @@ -102,7 +110,7 @@ chat never blocks CI on that pod.

| Flag | Environment | Default | |
| --- | --- | --- | --- |
| `--connection` | `BENCHPOD_CONNECTION` | — | host[:port], serial device, `usb`, `discover`, or `embeddedci:<device>` |
| `--connection` | `BENCHPOD_CONNECTION` | mDNS | host[:port], serial device, `usb`, `discover`, or `embeddedci:<device>`; unset = 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 Expand Up @@ -144,7 +152,7 @@ description, ranges and enums, so an agent sees more than this table.

| Tool | Purpose | Parameters |
| --- | --- | --- |
| `connect` | Open a connection (closing any previous one) and report status and capabilities. | `connection`, `la_voltage`, `lease_wait=30` |
| `connect` | Open a connection (closing any previous one) and report status and capabilities. Without a `connection` or a configured default, finds the one pod on the LAN over mDNS. | `connection`, `la_voltage`, `lease_wait=30` |
| `disconnect` | Close UART/CAN sessions and the connection, releasing a cloud lease. | none |
| `status` | Firmware, capabilities, LA voltage, open sessions and warnings (works when not connected). | none |
| `set_la_voltage` | Select the LA I/O voltage to match the DUT (1.8 V needs a rev3 pod). | **`voltage`** |
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 to use the server default). 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 or BENCHPOD_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.
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
6 changes: 4 additions & 2 deletions packages/embeddedci-mcp/src/embeddedci_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,8 @@ def _status() -> m.StatusResult:
if not SESSION.connected and not SESSION.reconnectable:
default = SESSION.default_connection
hint = (f"call connect (the server default is {default!r})" if default
else "call connect with a host, /dev/tty…, 'usb' or 'embeddedci:<device>'")
else "call connect: without a connection it finds the one pod on the LAN over mDNS; "
"else pass a host, 'usb' or 'embeddedci:<device>'")
return m.StatusResult(connected=False, warnings=[f"not connected — {hint}"])
pod = SESSION.require()
firmware = pod.status()
Expand Down Expand Up @@ -272,7 +273,8 @@ def _status() -> m.StatusResult:
async def connect(
connection: Annotated[Optional[str], Field(description=(
"host[:port] (TCP, default port 8080), a serial device path, 'usb' (auto-detect), "
"'discover' (mDNS) or 'embeddedci:<device-name>' (cloud). Omit for the server default."))] = None,
"'discover' (mDNS) or 'embeddedci:<device-name>' (cloud). Omit to use the server default "
"(--connection or BENCHPOD_CONNECTION), else to find the one pod on the LAN over mDNS."))] = None,
la_voltage: Annotated[Optional[Literal[1.8, 3.3]], Field(description=(
"Select the LA I/O-bank voltage right after connecting (the DUT's I/O voltage)."))] = None,
lease_wait: Annotated[float, Field(ge=0, le=3600, description=(
Expand Down
33 changes: 28 additions & 5 deletions packages/embeddedci-mcp/src/embeddedci_mcp/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
)
from embeddedci.benchpod.client import API_BASE_ENV, API_KEY_ENV
from embeddedci.benchpod.cloud_auth import DEFAULT_API_BASE
from embeddedci.benchpod import connection as _connection
from embeddedci.benchpod.connection import CLOUD_PREFIX, DISCOVER_KEYWORDS, ENV_VAR, _is_device_path
from embeddedci.benchpod.errors import BenchPodError, ConnectionConfigError

Expand Down Expand Up @@ -68,14 +69,39 @@ def cloud_user_token(connection: str) -> Optional[Callable[[], str]]:
return lambda: cli_login.access_token(base)


#: What to pass instead when mDNS finds no single pod.
CONNECTION_HELP = (
"pass connection= one of: host[:port] (a pod on the LAN, default port 8080), 'usb' (a pod on "
"this machine's USB), or 'embeddedci:<device>' for a cloud pod (cloud_list_devices lists "
"them). The user can also make one the default with " + ENV_VAR + " or --connection."
)


def discover_connection() -> str:
"""Find the single BenchPod on the LAN over mDNS and return its ``host:port``.

No pod, several pods or no mDNS support raise :class:`ConnectionConfigError` whose message
says what was found (the names and addresses when there are several) and what to pass instead.
"""
try:
spec = _connection._discover_one()
except ConnectionConfigError as exc:
raise ConnectionConfigError(f"no connection configured and mDNS discovery did not settle "
f"on one pod: {exc}\nTo connect, {CONNECTION_HELP}") from exc
except (RuntimeError, OSError) as exc: # zeroconf missing, or no usable network interface
raise ConnectionConfigError(f"no connection configured and mDNS discovery failed ({exc}). "
f"To connect, {CONNECTION_HELP}") from exc
return spec.addr


class Session:
"""At most one open BenchPod connection, plus the sessions opened on it."""

def __init__(self) -> None:
self.lock = threading.RLock()
self._pod: Optional[BenchPod] = None
#: Connection used by ``connect`` when called without one (``--connection``); falls back to
#: ``BENCHPOD_CONNECTION``.
#: ``BENCHPOD_CONNECTION``, then to mDNS discovery of a single pod on the LAN.
self.default_connection: Optional[str] = None
#: LA voltage applied on connect when the tool call does not pass one (``--la-voltage``);
#: falls back to ``BENCHPOD_LA_VOLTAGE`` inside the SDK.
Expand Down Expand Up @@ -121,10 +147,7 @@ def connect(self, connection: Optional[str] = None, *, la_voltage: Optional[floa
self.disconnect()
conn = connection or self.default_connection or os.environ.get(ENV_VAR)
if not conn:
raise ConnectionConfigError(
"no connection given: pass one (host[:port], /dev/tty…, 'usb' or "
f"'embeddedci:<device>'), start the server with --connection, or set {ENV_VAR}"
)
conn = discover_connection()
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)
Expand Down
68 changes: 68 additions & 0 deletions packages/embeddedci-mcp/tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,74 @@ def test_connect_uses_the_server_default(fake_open):
assert fake_open["connection"] == "embeddedci:bench-1" and fake_open["la_voltage"] == 1.8


def _mdns(monkeypatch, *pods):
"""Make mDNS discovery hear ``pods`` (name, address) instead of browsing the LAN."""
from embeddedci.benchpod import discovery

found = [discovery.DiscoveredPod(name=name, hostname=f"{name}.local", addresses=[ip], port=8080,
pod_id="abcdefghijklmnop") for name, ip in pods]
monkeypatch.setattr(discovery, "discover", lambda timeout=discovery.DEFAULT_TIMEOUT: found)


def test_connect_without_any_connection_finds_the_pod_over_mdns(fake_open, monkeypatch):
_mdns(monkeypatch, ("benchpod-baea06", "192.168.1.220"))
result = call("connect")
assert fake_open["connection"] == "192.168.1.220:8080"
assert result["connected"] is True and result["kind"] == "tcp"
assert result["connection"] == "192.168.1.220:8080"


@pytest.mark.parametrize("configured", ["env", "server"])
def test_a_configured_connection_skips_mdns(fake_open, monkeypatch, configured):
from embeddedci.benchpod import discovery

def no_browse(timeout=None):
raise AssertionError("mDNS must not run when a connection is configured")

monkeypatch.setattr(discovery, "discover", no_browse)
if configured == "env":
monkeypatch.setenv("BENCHPOD_CONNECTION", "10.0.0.7")
else:
SESSION.default_connection = "10.0.0.7"
call("connect")
assert fake_open["connection"] == "10.0.0.7"


def test_connect_with_no_pod_on_the_lan_says_what_to_pass(fake_open, monkeypatch):
_mdns(monkeypatch)
with pytest.raises(ToolError) as err:
call("connect")
msg = str(err.value)
assert "ConnectionConfigError" in msg and "no BenchPod found" in msg
for needle in ("host[:port]", "'usb'", "'embeddedci:<device>'", "cloud_list_devices",
"BENCHPOD_CONNECTION"):
assert needle in msg
assert not SESSION.connected and "connection" not in fake_open


def test_connect_with_several_pods_lists_them(fake_open, monkeypatch):
_mdns(monkeypatch, ("benchpod-baea06", "192.168.1.220"), ("benchpod-435dca", "192.168.1.221"))
with pytest.raises(ToolError) as err:
call("connect")
msg = str(err.value)
assert "2 BenchPods found" in msg
for needle in ("benchpod-baea06", "192.168.1.220:8080", "benchpod-435dca", "192.168.1.221:8080",
"host[:port]", "'usb'", "cloud_list_devices"):
assert needle in msg
assert not SESSION.connected and "connection" not in fake_open


def test_connect_without_mdns_support_says_what_to_pass(fake_open, monkeypatch):
from embeddedci.benchpod import discovery

def missing(timeout=None):
raise RuntimeError("mDNS discovery needs the 'zeroconf' package")

monkeypatch.setattr(discovery, "discover", missing)
with pytest.raises(ToolError, match=r"(?s)ConnectionConfigError.*zeroconf.*host\[:port\]"):
call("connect")


def test_schema_rejects_bad_arguments(connected):
with pytest.raises(ToolError):
call("set_la_voltage", voltage=5.0)
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 for the server default.",
"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 or BENCHPOD_CONNECTION), else to find the one pod on the LAN over mDNS.",
"title": "Connection"
},
"la_voltage": {
Expand Down
Loading