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
15 changes: 15 additions & 0 deletions packages/embeddedci-mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

## Unreleased

- `connect` without a connection now uses, in order: `--connection`, `BENCHPOD_CONNECTION`, the
default connection `benchpod-cli` saved (`benchpod set-connection`), then mDNS discovery. The
saved connection needs embeddedci 2.10 or newer; with an older SDK the step is skipped.
**Behavior change:** on a machine with a saved CLI connection, `connect()` goes there instead of
discovering a pod over mDNS.
- One wiring profile everywhere: on a LAN or USB connection the `wiring` tool (and every tool that
falls back to the profile) now uses the profile stored on embeddedci.com for the pod, as the web
UI's Wiring tab shows it, when `BENCHPOD_API_KEY` or the `benchpod login` session is available
and the pod is registered on that account (embeddedci 2.10 or newer). `BENCHPOD_WIRING` still
wins, `BENCHPOD_WIRING_SOURCE=local` skips the lookup, and any failure falls back to the
defaults within about 3 seconds. **Behavior change:** a signed-in user's registered LAN pod
reports `source: "server"` instead of `"defaults"` when a profile is stored.
- `connect` and `status` report `wiring_source` (`server`, `file`, `dict` or `defaults`).
- The `benchpod login` session is also handed to the SDK for a LAN or USB connection when a login
exists, so the waveform library works there without an API key too.
- `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
Expand Down
36 changes: 29 additions & 7 deletions packages/embeddedci-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,18 @@ 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
Both variables are optional. `connect` picks the pod, first match wins:

1. its `connection` argument;
2. the server default, `--connection`;
3. `BENCHPOD_CONNECTION`;
4. the default connection `benchpod-cli` saved with `benchpod set-connection <address|device|usb>`
(read-only, from `~/.config/benchpod-cli/config.json`, or under `$XDG_CONFIG_HOME`);
5. mDNS discovery of the one pod on the LAN.

So the CLI, the SDK and the MCP server share one saved connection. With none of the first four,
`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
Expand Down Expand Up @@ -93,8 +102,9 @@ transport is included:
```

Or skip the API key: after `benchpod login`, the server reuses that session
(`~/.config/benchpod-cli/token.json`, refreshed and written back when it has expired) for both
`cloud_list_devices` and `connect("embeddedci:<name>")`. `BENCHPOD_API_KEY` wins when set, and
(`~/.config/benchpod-cli/token.json`, refreshed and written back when it has expired) for
`cloud_list_devices`, `connect("embeddedci:<name>")` and, on a LAN or USB connection, the pod's
stored wiring profile. `BENCHPOD_API_KEY` wins when set, and
inside GitHub Actions the SDK uses OIDC instead.

`cloud_list_devices` lists the pods on your account and which are online, without connecting to
Expand All @@ -110,10 +120,12 @@ chat never blocks CI on that pod.

| Flag | Environment | Default | |
| --- | --- | --- | --- |
| `--connection` | `BENCHPOD_CONNECTION` | mDNS | host[:port], serial device, `usb`, `discover`, or `embeddedci:<device>`; unset = find the one pod on the LAN |
| `--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 |
| `--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 |
| — | `BENCHPOD_WIRING` | — | wiring profile file (`.json`/`.toml`) that wins over the stored profile |
| — | `BENCHPOD_WIRING_SOURCE` | `auto` | `local` never asks embeddedci.com for the pod's stored wiring profile |
| `--lease-wait` | — | `30` | cloud: seconds to wait for a busy pod |
| `--idle-timeout` | — | `600` | cloud: release the lease after this many idle seconds (0 = never) |
| `--timeout` | — | `30` | per-command device timeout |
Expand Down Expand Up @@ -152,7 +164,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. Without a `connection` or a configured default, finds the one pod on the LAN over mDNS. | `connection`, `la_voltage`, `lease_wait=30` |
| `connect` | Open a connection (closing any previous one) and report status, capabilities and where the wiring profile came from. Without a `connection` or a configured default (`--connection`, `BENCHPOD_CONNECTION`, the CLI's saved connection), 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 Expand Up @@ -356,6 +368,16 @@ resistors and analog paths) and `benchpod://help` (the server instructions).
agent that read `wiring` once can call `flash()`, `uart_open()` or `power_on()` with no
pin numbers at all. `set_wiring` replaces it for the connection, or stores it on embeddedci.com
with `save: true`.

It is the same profile the web UI's Wiring tab edits. The SDK resolves it, first match wins:
`BENCHPOD_WIRING` (a file); the profile stored on embeddedci.com for this pod (a cloud pod by
name; a LAN or USB pod by the device it reports it is registered as, when `BENCHPOD_API_KEY` or
the `benchpod login` session is available and that device is on the same account); the
defaults. `wiring` and `connect`/`status` (`wiring_source`) report where it came from: `server`,
`file`, `dict` (from `set_wiring`) or `defaults`. When the lookup fails (not signed in, pod not
registered, server unreachable, another account) the defaults apply and nothing fails; over the
LAN or USB it gives up after about 3 seconds. The STM32 pod's USB text console cannot report its
registration, so over USB the defaults apply. `BENCHPOD_WIRING_SOURCE=local` skips the lookup.
- **Agent-sized captures.** `capture_adc` returns calibrated statistics, the dominant frequency and a
min/max envelope; `capture_la` returns per-channel levels, edges and frequencies. The last captures
stay in the session, so `decode_la`, `replay(from_last_capture=true)` and
Expand Down
4 changes: 2 additions & 2 deletions packages/embeddedci-mcp/src/embeddedci_mcp/guide.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
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 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.
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.
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.

Cloud pods: cloud_list_devices lists the pods registered on embeddedci.com (name, online) without connecting to any; connect to one with its `connection` ('embeddedci:<name>'). Tools named cloud_* talk to embeddedci.com, not to a pod. Cloud access (cloud_* and connect to 'embeddedci:<name>') authenticates with BENCHPOD_API_KEY, else the user's `benchpod login` session; if neither exists, ask the user to run `benchpod login`.

Wiring: the pod has 14 generic LA channels (LA1-LA14) and nothing is fixed. Call `wiring` first: it is the bench's profile: which DUT signal is on which channel, the target-power rail, the UART baud, the SWD target. Every channel, baud, rail or SWD argument you omit comes from it, and channel arguments also accept its names ("READY", "uart_rx"). Confirm it with the user if it looks wrong; `set_wiring` replaces it (save=true stores it on embeddedci.com). Target power is internal 5 V (efuse 1) or external (efuse 2). The DUT's reset line goes to the pod's own reset pin: flash(nreset=true), reset_target.
Wiring: the pod has 14 generic LA channels (LA1-LA14) and nothing is fixed. Call `wiring` first: it is the bench's profile (the one stored on embeddedci.com and shown in the web UI's Wiring tab when the pod is registered and the user is signed in; `source` says where it came from): which DUT signal is on which channel, the target-power rail, the UART baud, the SWD target. Every channel, baud, rail or SWD argument you omit comes from it, and channel arguments also accept its names ("READY", "uart_rx"). Confirm it with the user if it looks wrong; `set_wiring` replaces it (save=true stores it on embeddedci.com). Target power is internal 5 V (efuse 1) or external (efuse 2). The DUT's reset line goes to the pod's own reset pin: flash(nreset=true), reset_target.

GPIO: each LA channel has one owner at a time. gpio_mode claims channels (input/output/open_drain), gpio_write drives them, gpio_read reads any channel's live level, gpio_wait polls for one, gpio_pulse emits FPGA-timed pulses, gpio_release frees them. Release a GPIO channel before uart_open, flash or enable_i2c_sensor uses it, otherwise the pod refuses with "pin conflict: LA4 is in use by gpio; release it with gpio_release(la=[4])": the error names the tool that frees the channel (gpio_release, uart_close, disable_i2c_sensor, disable_gps); la_pins shows every channel's owner. Captures observe all 14 channels whatever owns them.

Expand Down
7 changes: 6 additions & 1 deletion packages/embeddedci-mcp/src/embeddedci_mcp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ class StatusResult(BaseModel):
session: SessionInfo = Field(default_factory=SessionInfo)
warnings: List[str] = Field(default_factory=list)
firmware: Dict[str, Any] = Field(default_factory=dict, description="The pod's raw status report.")
wiring_source: Optional[str] = Field(None, description=(
"Where this connection's wiring profile came from: server (stored on embeddedci.com, as the "
"web UI's Wiring tab shows it), file (BENCHPOD_WIRING), dict (set_wiring) or defaults."))


class LaVoltageResult(BaseModel):
Expand Down Expand Up @@ -134,7 +137,9 @@ class WiringPin(BaseModel):
class WiringResult(BaseModel):
"""The bench's effective wiring profile: which DUT signal is on which LA channel."""

source: str = Field(description="Where the profile came from: defaults, file, server or dict.")
source: str = Field(description=(
"Where the profile came from: server (stored on embeddedci.com for this pod, over the cloud "
"or, signed in, over the LAN/USB), file (BENCHPOD_WIRING), dict (set_wiring) or defaults."))
version: int = 1
la_voltage: float = Field(description="LA I/O-bank voltage the profile asks for, in volts.")
efuse: int = Field(description=(
Expand Down
12 changes: 9 additions & 3 deletions packages/embeddedci-mcp/src/embeddedci_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,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: without a connection it finds the one pod on the LAN over mDNS; "
else "call connect: without a connection it uses the one saved with `benchpod "
"set-connection`, else 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()
Expand All @@ -313,13 +314,17 @@ 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")
try:
wiring_source: Optional[str] = pod.wiring.source
except (BenchPodError, ValueError, OSError):
wiring_source = None
if la is None:
warnings.append("LA I/O voltage not selected — call set_la_voltage (1.8 or 3.3, matching "
"the DUT) before flash, UART, LA capture, pulls or I2C-sensor emulation")
return m.StatusResult(
connected=True, connection=SESSION.connection, kind=SESSION.kind, leased=pod.leased,
la_voltage=la, capabilities=m.CapabilitiesInfo.from_caps(pod.capabilities),
session=SESSION.info(), warnings=warnings, firmware=firmware,
session=SESSION.info(), warnings=warnings, firmware=firmware, wiring_source=wiring_source,
)


Expand All @@ -328,7 +333,8 @@ 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 to use the server default "
"(--connection or BENCHPOD_CONNECTION), else to find the one pod on the LAN over mDNS."))] = None,
"(--connection, else BENCHPOD_CONNECTION, else the connection saved with `benchpod "
"set-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
29 changes: 22 additions & 7 deletions packages/embeddedci-mcp/src/embeddedci_mcp/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,21 +59,34 @@ def connection_kind(connection: str) -> str:


def cloud_user_token(connection: str) -> Optional[Callable[[], str]]:
"""For a cloud connection without BENCHPOD_API_KEY, outside GitHub Actions (which uses OIDC):
the `benchpod login` session, exchanged by the SDK for a device session token."""
if connection_kind(connection) != "embeddedci" or os.environ.get(API_KEY_ENV):
"""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
pod (only when a login exists) the SDK reads the pod's stored wiring profile with it, so the
`wiring` tool shows what the web UI's Wiring tab shows."""
if os.environ.get(API_KEY_ENV) or os.environ.get("GITHUB_ACTIONS") == "true":
return None
if os.environ.get("GITHUB_ACTIONS") == "true":
if connection_kind(connection) != "embeddedci" and not cli_login.token_path().is_file():
return None
base = os.environ.get(API_BASE_ENV) or DEFAULT_API_BASE
return lambda: cli_login.access_token(base)


def saved_connection() -> Optional[str]:
"""The default connection `benchpod-cli` saved (`benchpod set-connection`), or None. Needs an
SDK that reads it (embeddedci 2.10); older ones have none."""
try:
from embeddedci.benchpod.cli_config import saved_connection as _saved
except ImportError: # pragma: no cover - embeddedci < 2.10
return None
return _saved()


#: 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."
"them). The user can also make one the default with `benchpod set-connection <target>`, "
+ ENV_VAR + " or --connection."
)


Expand Down Expand Up @@ -101,7 +114,8 @@ 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``, then to mDNS discovery of a single pod on the LAN.
#: ``BENCHPOD_CONNECTION``, then to the connection ``benchpod-cli`` saved, 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 @@ -145,7 +159,8 @@ def connect(self, connection: Optional[str] = None, *, la_voltage: Optional[floa
"""Open (or re-open) the device, closing any prior connection and sessions first."""
with self.lock:
self.disconnect()
conn = connection or self.default_connection or os.environ.get(ENV_VAR)
conn = (connection or self.default_connection or os.environ.get(ENV_VAR)
or saved_connection())
if not conn:
conn = discover_connection()
la = self.default_la_voltage if la_voltage is None else la_voltage
Expand Down
13 changes: 13 additions & 0 deletions packages/embeddedci-mcp/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

import math
import os
from typing import Any, Dict, Iterator, List, Optional

import anyio
Expand Down Expand Up @@ -431,3 +432,15 @@ def connected(fake_open, fake_transport) -> FakeTransport:
"""A session connected (via the connect tool) to the fake pod, LA bank at 3.3 V."""
call("connect", connection="192.168.1.50", la_voltage=3.3)
return fake_transport


@pytest.fixture(autouse=True)
def _isolated_benchpod_cli_config(request, tmp_path_factory, monkeypatch):
"""Unit tests never see this machine's benchpod-cli config (its saved connection) or its
`benchpod login` session: both change what BenchPod() connects to and where its wiring comes
from. Hardware tests (e2e, examples) keep them."""
path = str(request.node.fspath)
if "e2e" in os.path.basename(path) or os.sep + "e2e" + os.sep in path \
or os.sep + "examples" + os.sep in path:
return
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path_factory.mktemp("xdg")))
Loading