Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

gallo-mcp — MCP server

gallo-mcp bridges a Pico de Gallo to AI coding agents. It runs a Model Context Protocol server over stdio, wrapping pico-de-gallo-lib and exposing one tool per peripheral operation across I2C, SPI, UART, GPIO, PWM, ADC, and 1-Wire.

The point is the on-your-laptop development loop: while an agent writes an embedded driver, it can probe and drive real hardware through the same board gallo talks to — reading a sensor register, scanning a bus, toggling a pin — without cross-compiling or flashing anything.

Use it for:

  • letting an AI agent explore an unfamiliar device interactively,
  • generating and validating register-level driver code against real silicon,
  • turning “what does this chip return?” into a tool call the agent can make itself.

Install

$ cargo install gallo-mcp

Run

$ gallo-mcp [--serial-number <SN>]

gallo-mcp speaks the MCP protocol on stdout and logs to stderr, so it is meant to be launched by an MCP client rather than run interactively.

  • -s, --serial-number <SN> pins the server to one board. A pinned server cannot address any other board: a tool call naming a different serial is refused. This is the way to scope an agent session to a single board. If the pinned serial is not among the attached boards at startup, the server logs a warning naming the serials it did find, and starts anyway — see below.
  • Per-call connection: the server holds no persistent USB claim. Each tool call opens the board, runs, and releases it when the call completes, so the device is free for the gallo CLI or other host processes between calls. Each call re-opens and re-validates the board, so there is a small fixed per-call connection cost. The server starts even with no board attached and tools begin working as soon as a Pico de Gallo is present; you can plug the board in mid-session.

The pin is checked once at startup, and an unusable one is a warning rather than a startup failure — starting with no board attached has to keep working, so the server cannot treat “pin not attached” as fatal:

WARN gallo_mcp: This server is pinned to serial number 'BOGUSSERIAL' (--serial-number), which is not attached.
Available: 5256657D8A5D7F03

Nothing is logged when the pin resolves, when no pin was given, or when no board is attached at all — that last case is indistinguishable from “not plugged in yet”, which is supported. So the warning firing means a board is attached and the pin does not match it, which in practice means a typo. Without it a mistyped pin starts a server that looks healthy and then fails every device call for the rest of the session.

This warning is on by default — it has to be, because MCP clients launch the server with whatever environment they have and rarely set RUST_LOG. Logs go to stderr, so they never disturb the JSON-RPC stream on stdout. RUST_LOG still controls verbosity when you set it, and overrides the default entirely: RUST_LOG=error silences the warning, RUST_LOG=gallo_mcp=debug adds the per-call board-lock tracing.

Protocol revisions

gallo-mcp supports every MCP revision its SDK knows — 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25, and 2026-07-28 — and negotiates during initialize: it echoes the revision the client asks for when it is one of those, and otherwise falls back to 2025-11-25. The initialize result identifies the server as gallo-mcp with this crate’s version.

The tool surface does not vary by revision. The same 43 tools, with the same names, arguments, annotations, and JSON payloads, are served to every client. What changes is the envelope around them:

2025-11-25 and older2026-07-28
resultType on resultsabsent"complete"
ttlMs / cacheScope cache hintsabsentpresent
server/discovernot availableanswered

A client that negotiates 2026-07-28 may therefore start with a stateless server/discover instead of initialize, and will see the SEP-2322 result discriminator and SEP-2549 cache hints on every result. Everything else — tool payloads, the serial_number echo, error mapping — is byte-for-byte identical across revisions.

Choosing a board

Every tool except list_devices takes an optional serial_number, and every response that came from a board names the board it came from:

// i2c_scan {"serial_number":"5256657D8A5D7F03"}
{
  "serial_number": "5256657D8A5D7F03",
  "result": { "addresses": ["0x48"], "raw": [72] }
}

That { "serial_number", "result" } envelope wraps every device tool response but two. list_devices opens no board at all, and status reports its serial as a top-level field of its own result rather than nesting a serial under a serial.

The envelope’s serial_number is null in exactly one case: the sole attached board reports no USB serial number. The field is always present, so null there means “a board that has no serial answered” — never “this response was not enveloped”. status’s available list carries null entries for the same reason.

How the target is chosen:

Boards attachedserial_numberResult
0error: no device attached
1omittedthat board
1giventhat board, if it matches
≥2omittederror, listing the available serials
≥2giventhe named board, if exactly one matches

With one board attached nothing changes — omit serial_number and it just works. With two or more, omitting it is an error rather than a guess:

Multiple Pico de Gallo devices attached; `serial_number` is required.
Available: 5256657D8A5D7F03, 568E9AAEC72B0D49

That is deliberate. Guessing turns a recoverable mistake into a confident wrong answer with no signal that anything went wrong; the error names the serials, so the next call succeeds.

Two boards can also report the same serial, which is refused for the same reason — naming it no longer identifies a board:

2 attached Pico de Gallo devices report serial number '0000000000000000'; they cannot be told apart. Detach all but one and retry.

That is reachable rather than hypothetical. The firmware derives the USB serial from the RP2350 chip ID and falls back to all-zeros when the OTP read fails, which its own comment notes happens on some dev boards, so two such boards collide. No argument fixes it; detach one.

A server started with -s is pinned, and the two ≥2 rows above no longer apply: serial_number is optional again however many boards are attached. An omitted argument uses the pinned board, a matching one is accepted, and a different one is refused:

This server is pinned to serial number '5256657D8A5D7F03' (--serial-number); it cannot address '568E9AAEC72B0D49'. Omit serial_number, or pass '5256657D8A5D7F03'.

Device state is per board — bus configuration, GPIO direction, PWM enable, and 1-Wire search progress all live on the board you addressed — so a follow-up call must repeat the serial_number of the call that set it up.

list_devices tells you which case you are in without connecting:

// list_devices {}
{
  "devices": [
    { "serial_number": "5256657D8A5D7F03", "manufacturer": null,
      "product": "Pico de Gallo", "pinned": false, "default_target": false },
    { "serial_number": "568E9AAEC72B0D49", "manufacturer": null,
      "product": "Pico de Gallo", "pinned": false, "default_target": false }
  ],
  "pinned": null,
  "serial_number_required": true,
  "note": "2 devices attached and this server is not pinned; pass serial_number on every device tool call."
}

note is present only when serial_number_required is true.

status never errors, so it stays answerable even when the target is ambiguous:

// status {}
{
  "attached": true,
  "serial_number": null,
  "ambiguous": true,
  "available": ["5256657D8A5D7F03", "568E9AAEC72B0D49"],
  "pinned": null,
  "reason": "Multiple Pico de Gallo devices attached; `serial_number` is required.\nAvailable: 5256657D8A5D7F03, 568E9AAEC72B0D49",
  "firmware_version": null,
  "schema_major": null,
  "schema_minor": null,
  "build_id": null
}

ambiguous answers “would a call that omits serial_number be ambiguous?” — not “would it fail?”. A bare call also fails with no board attached, and when the server is pinned to a board that is not attached; ambiguous is false in both. It stays true even when this particular status call named a board. reason is present only when no board was reached.

When status reaches a board, build_id contains that firmware image’s git describe identity; the key is always present and remains null before a connection succeeds. device_info returns the same identity. Each successful connection also logs the serial number, firmware version, and build identity at info level on stderr. The default filter enables that event; set RUST_LOG to override it.

Concurrency

The connection lock is keyed on the board, not on the server. Calls to different boards run concurrently; calls to the same board queue. A long-running tool — a gpio_wait_* sitting on its full timeout_ms — holds only the board it addressed, so traffic to every other board keeps flowing. Firmware clamps that timeout to a maximum of 30 minutes.

list_devices connects to nothing and always answers immediately. status does open the board it names, so it queues behind a call that is still holding that board.

Using it with an MCP client

Add gallo-mcp as a local (stdio) server in your client’s config. These files are safe to commit per-project, so the tools appear only in repos that opt in.

opencode (opencode.json)

{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "pico-de-gallo": { "type": "local", "command": ["gallo-mcp"], "enabled": true }
  }
}

Claude Code (.mcp.json)

{ "mcpServers": { "pico-de-gallo": { "command": "gallo-mcp", "args": [] } } }

Cursor (.cursor/mcp.json)

{ "mcpServers": { "pico-de-gallo": { "command": "gallo-mcp", "args": [] } } }

Byte conventions

Byte payloads go in as hex strings and come back as both hex and a decimal array. Input accepts comma-separated or bare hex ("0x00,0x10" or "0010"); reads return { "hex": ..., "bytes": ... } inside the response envelope.

// i2c_write_read {"address":72,"data":"0x00","count":2}
{
  "serial_number": "5256657D8A5D7F03",
  "result": { "hex": "0x0B,0x8E", "bytes": [11, 142] }
}

Tool catalog

43 tools, grouped by peripheral. Read-only tools carry the readOnlyHint annotation; write/actuation tools carry destructiveHint. Every tool except list_devices accepts an optional serial_number.

device (read-only)

ToolDescription
list_devicesList connected Pico de Gallo boards
statusWhich board is reachable, why not when none is, plus firmware/schema/build identity
device_infoFirmware version, schema version, capabilities, runtime GPIO count, build identity
versionFirmware version
pingEcho a value (liveness check)

i2c

ToolDescriptionKind
i2c_readRead bytes from a target addressread-only
i2c_write_readWrite then read without releasing the busread-only
i2c_scanProbe the bus for responding addressesread-only
i2c_get_configShow the active I2C frequencyread-only
i2c_writeWrite bytes to a target addressdestructive
i2c_set_configSet the I2C frequencydestructive
i2c_batchAtomic multi-step transaction under one I2C addressdestructive

spi

ToolDescriptionKind
spi_readClock in bytesread-only
spi_transferFull-duplex transferread-only
spi_get_configShow the active SPI configurationread-only
spi_writeClock out bytesdestructive
spi_flushFlush the SPI bufferdestructive
spi_set_configSet frequency, phase, and polaritydestructive
spi_batchAtomic multi-step transaction under chip-selectdestructive

Note

MCP tools refuse over-ceiling payloads locally, before connecting to a board. Data returned by the device is limited to MAX_RESPONSE_PAYLOAD (1014 bytes), while data sent to it is limited to MAX_TRANSFER_SIZE (4096 bytes). Full-duplex spi_transfer is therefore limited to 1014 bytes even though spi_write accepts 4096. i2c_batch and spi_batch are bounded a third way: their aggregate outgoing bytes must fit one MAX_REQUEST_FRAME (5119-byte) request frame. The refusal is an invalid_params error naming the offending size, the limit and the remedy — not the bare BufferTooLong a device-side refusal would produce. See troubleshooting.

Refusing before connecting is deliberate: connect runs system/reset-subscriptions, which tears down GPIO subscriptions belonging to other host processes, so a mis-sized request must not reach it (issue #104).

spi_batch takes cs as a u8 and runs its steps in a fixed order: parse every operation payload, connect exactly once, read the GPIO count from the DeviceInfo that connection already validated, classify cs, then call the library once. No second metadata query is issued.

Parsing comes first on purpose. connect runs system_reset_subscriptions, which tears down every GPIO subscription on the board — including ones owned by other host processes — so a malformed request must not reach it. A payload that fails to parse returns -32602 (invalid params) without connecting.

An out-of-range cs and a device reporting zero GPIOs are also -32602, with distinct messages, and nothing is transmitted. A failure to establish the count — transport, 300-second device/info timeout, legacy firmware, or schema mismatch — is -32603 (internal error), never -32602: it is not a complaint about your argument.

uart

ToolDescriptionKind
uart_readRead bytes with a timeoutread-only
uart_get_configShow the active UART configurationread-only
uart_writeWrite raw bytesdestructive
uart_flushDrain the transmit bufferdestructive
uart_set_configSet baud rate, data bits, parity, and stop bitsdestructive

uart_set_config requires baud_rate and accepts optional string fields data_bits ("5""8"), parity ("none", "odd", "even", "mark", or "space"), stop_bits ("1" or "2"), and serial_number. Omitting a framing field selects its 8N1 power-on value and overwrites the previous value, because the tool replaces the complete configuration. Repeat the framing fields for a baud-only change.

// uart_set_config
{"baud_rate":9600,"data_bits":"7","parity":"even","stop_bits":"2"}

Unlike write and flush, the result is not a bare "ok": it returns the applied baud_rate, data_bits, parity, and stop_bits inside the usual { "serial_number", "result" } envelope. These spellings can be passed directly to a later uart_set_config call. Reconfiguration is not atomic—the divisor is applied before framing and neither direction is drained—so quiesce transmit and receive traffic during the call.

gpio

ToolDescriptionKind
gpio_getRead the current level of a pinread-only
gpio_wait_for_rising_edge_with_timeoutWait for a rising edge (bounded)read-only
gpio_wait_for_falling_edge_with_timeoutWait for a falling edge (bounded)read-only
gpio_wait_for_any_edge_with_timeoutWait for any edge (bounded)read-only
gpio_putDrive a pin high or lowdestructive
gpio_set_configSet direction and pull resistordestructive

pwm

ToolDescriptionKind
pwm_get_duty_cycleRead current and maximum dutyread-only
pwm_get_configShow the active PWM configurationread-only
pwm_set_duty_cycleSet a raw duty-cycle valuedestructive
pwm_enableEnable the slice behind a channeldestructive
pwm_disableDisable the slice behind a channeldestructive
pwm_set_configSet frequency and phase-correct modedestructive

adc (read-only)

ToolDescription
adc_readRead one ADC sample
adc_get_configShow resolution, reference, and channel count

onewire

ToolDescriptionKind
onewire_readRead raw bytesread-only
onewire_searchEnumerate ROM IDs on the busread-only
onewire_resetReset the bus and report presencedestructive
onewire_writeWrite raw bytesdestructive
onewire_write_pullupWrite, then hold the line high (parasitic power)destructive

GPIO waits and v1 limits

GPIO edge waits are timeout-bounded only: each of the three wait tools requires a non-zero timeout_ms. This release deliberately does not expose 0, which would select the firmware’s 30-minute ceiling, or push-based edge subscriptions — holding an MCP request for that full ceiling is undesirable, and event streaming is out of scope for v1. Oversized non-zero values are clamped to the same ceiling; expiry returns the GPIO endpoint’s Timeout error.

uart_read’s timeout_ms is not covered by that rule: there 0 is legal and performs a single non-blocking poll, returning whatever is already buffered (possibly nothing). Its non-zero path is clamped to the 30-minute ceiling. The asymmetry is deliberate.

uart_read is also one of the twelve tools that hard-fail on a hw-rev1 board: that revision supports only I2C, SPI, GPIO, and PWM, so every uart_*, adc_*, and onewire_* call returns Unsupported there. Two boards on one bench can be different revisions, so call device_info per board rather than assuming they are interchangeable.

Security

gallo-mcp does not gate writes itself. Write approval is delegated to the MCP client through tool annotations:

  • read tools are marked readOnlyHint,
  • write and actuation tools are marked destructiveHint.

A well-configured client uses those hints to prompt for confirmation before a destructive tool call. Configure your client’s permissions accordingly.

Warning

Under a permission-less or blanket-allow client, an agent can actuate hardware — drive pins, write buses, change configuration — without confirmation. If the board is wired to anything you care about, run the server behind a client that honors destructiveHint.

Every call resets that board’s GPIO subscriptions

Opening a board tears down every GPIO edge subscription on it, including ones owned by other host processes. A gallo CLI session or a user program watching a pin loses that watch the moment a tool call touches the same board.

That is the documented host protocol rather than a bug — it is how a host recovers pins stranded by a previous host that died mid-watch — but two things about it are easy to miss:

  • readOnlyHint does not cover it. status, device_info, version, ping, and i2c_scan are annotated read-only and appear in the read-only rows of the catalog below, yet each opens a board and so each resets its subscriptions. A client gating on destructiveHint will not prompt for any of them.
  • Per-call selection widened the blast radius. It used to reach only the single board the server was bound to. Now any call can name any attached board, so on a multi-board bench a call carrying serial_number for board B disturbs board B — even if every previous call in the session went to board A.

list_devices is the one exception: it opens no board, so it disturbs nothing.

If a long-lived watch matters, pin the server with -s to keep it away from the board running the watch.

Validation

The server was validated on real hardware: a Pico de Gallo (serial 5256657D8A5D7F03, firmware v0.10.1, schema v0.6.1, HW rev2) with a TMP108 temperature sensor on I2C.

Over stdio, status reports the attached board:

// status {}
{
  "attached": true,
  "serial_number": "5256657D8A5D7F03",
  "ambiguous": false,
  "available": ["5256657D8A5D7F03"],
  "pinned": null,
  "firmware_version": "0.12.0",
  "schema_major": 0,
  "schema_minor": 8,
  "build_id": "firmware-v0.12.0"
}

i2c_scan finds the sensor at address 0x48:

// i2c_scan {"include_reserved":false}
{
  "serial_number": "5256657D8A5D7F03",
  "result": { "addresses": ["0x48"], "raw": [72] }
}

And i2c_write_read reads its two temperature bytes:

// i2c_write_read {"address":72,"data":"0x00","count":2}
{
  "serial_number": "5256657D8A5D7F03",
  "result": { "hex": "0x0B,0x8E", "bytes": [11, 142] }
}

Those bytes are byte-for-byte identical to the gallo CLI:

$ gallo i2c write-read -a 0x48 -b 0x00 -c 2
0b 8e

Board selection was validated on the same bench with a second board attached (568E9AAEC72B0D49, bare bus): a bare i2c_scan is refused rather than answered from an arbitrary board, and naming either serial reaches that board. See Choosing a board.

Note

Decoding 0x0B8E into degrees Celsius is left to the reader and the TMP108 datasheet — it is not asserted here. The point of this walkthrough is that the MCP round-trip returns exactly what the CLI does, so an agent driving the board through gallo-mcp sees the same truth you would at the shell.