From 2ee6703b35458f464de8f275f4f3a892ce1d9311 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Fri, 9 Oct 2026 09:35:16 -0500 Subject: [PATCH] MCP: connect falls back to mDNS discovery when no connection is configured connect() with no connection, no --connection and no BENCHPOD_CONNECTION used to fail with "no connection given". It now finds the one pod on the LAN over mDNS (as 'discover' does). When discovery finds none or several pods, or mDNS is unavailable, the error lists the pods it heard and what to pass instead: host[:port], 'usb', or 'embeddedci:' from cloud_list_devices. The connect description, the status hint, the server instructions, the README and the CHANGELOG say so; unit tests mock discovery. Co-Authored-By: Claude Opus 5.5 --- packages/embeddedci-mcp/CHANGELOG.md | 9 +++ packages/embeddedci-mcp/README.md | 12 +++- .../src/embeddedci_mcp/guide.py | 2 +- .../src/embeddedci_mcp/server.py | 6 +- .../src/embeddedci_mcp/session.py | 33 +++++++-- packages/embeddedci-mcp/tests/test_tools.py | 68 +++++++++++++++++++ .../embeddedci-mcp/tests/tools_surface.json | 2 +- 7 files changed, 121 insertions(+), 11 deletions(-) diff --git a/packages/embeddedci-mcp/CHANGELOG.md b/packages/embeddedci-mcp/CHANGELOG.md index f17ec31..ab7e09c 100644 --- a/packages/embeddedci-mcp/CHANGELOG.md +++ b/packages/embeddedci-mcp/CHANGELOG.md @@ -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:` (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). diff --git a/packages/embeddedci-mcp/README.md b/packages/embeddedci-mcp/README.md index b25c0b8..47b54bb 100644 --- a/packages/embeddedci-mcp/README.md +++ b/packages/embeddedci-mcp/README.md @@ -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:` 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): @@ -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:` | +| `--connection` | `BENCHPOD_CONNECTION` | mDNS | host[:port], serial device, `usb`, `discover`, or `embeddedci:`; 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 | @@ -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`** | diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/guide.py b/packages/embeddedci-mcp/src/embeddedci_mcp/guide.py index f1002f4..90750e1 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/guide.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/guide.py @@ -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:' (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:'. 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. diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py index 86bd08b..2048b11 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py @@ -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:'") + else "call connect: without a connection it finds the one pod on the LAN over mDNS; " + "else pass a host, 'usb' or 'embeddedci:'") return m.StatusResult(connected=False, warnings=[f"not connected — {hint}"]) pod = SESSION.require() firmware = pod.status() @@ -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:' (cloud). Omit for the server default."))] = None, + "'discover' (mDNS) or 'embeddedci:' (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=( diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/session.py b/packages/embeddedci-mcp/src/embeddedci_mcp/session.py index 1a299dc..c5fefda 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/session.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/session.py @@ -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 @@ -68,6 +69,31 @@ 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:' 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.""" @@ -75,7 +101,7 @@ 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. @@ -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:'), 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) diff --git a/packages/embeddedci-mcp/tests/test_tools.py b/packages/embeddedci-mcp/tests/test_tools.py index b587c59..3aa493d 100644 --- a/packages/embeddedci-mcp/tests/test_tools.py +++ b/packages/embeddedci-mcp/tests/test_tools.py @@ -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:'", "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) diff --git a/packages/embeddedci-mcp/tests/tools_surface.json b/packages/embeddedci-mcp/tests/tools_surface.json index 477719a..fc1e164 100644 --- a/packages/embeddedci-mcp/tests/tools_surface.json +++ b/packages/embeddedci-mcp/tests/tools_surface.json @@ -808,7 +808,7 @@ } ], "default": null, - "description": "host[:port] (TCP, default port 8080), a serial device path, 'usb' (auto-detect), 'discover' (mDNS) or 'embeddedci:' (cloud). Omit for the server default.", + "description": "host[:port] (TCP, default port 8080), a serial device path, 'usb' (auto-detect), 'discover' (mDNS) or 'embeddedci:' (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": {