Back to Discover

serial-mcp

connector

qarnet

MCP server for serial port communication. 27 tools, resources, prompts, and stdio/HTTP transports.

View on GitHub
0 starsSynced Aug 2, 2026

Install to Claude Code

/plugin marketplace add qarnet/serial-mcp

README

Serial MCP Server

GitHub Release crates.io Rust License

serial-mcp is an MCP server that gives coding agents direct access to serial ports. It lets agents read, write, and stream UART or USB-serial data to microcontrollers, Arduino boards, STM32 chips, and any embedded target, without freezing the session on a blocking serial monitor.

Non-blocking reads with timeouts and pattern matching, background RX streaming, TX/RX frame decoding (line, delimiter, length-prefixed, start/end, SLIP, COBS) with AT, JSON, shell, NMEA-0183, and Modbus ASCII parsers, one-knob protocol presets (at_command, slip, json_lines, cobs, ndjson, nmea0183, modbus_ascii) with checksum validation, auto-reconnect, event logging, and full line control (DTR/RTS, BREAK, flow control) let Claude, Codex, or any MCP client flash, reset, and talk to a board on their own.

MCP 2025-11-25 compliant, with resource change notifications, a port allowlist, and stdio plus HTTP transports.

Capabilities

27 tools: list_ports, list_connections, open, close, read, write, transact, capture_boot, flush, set_dtr_rts, set_flow_control, send_break, subscribe, unsubscribe, get_status, reconfigure, list_profiles, open_profile, save_profile, delete_profile, configure, rollback_profile, get_log, clear_log, export_log, reconnect, compute_checksum 5 resources: serial://ports, serial://connections, serial://connections/{id}, serial://connections/{id}/raw, serial://connections/{id}/log (3 resource templates plus 2 static)
2 prompt templates: diagnose_port, interactive_terminal

The RX side uses an always-on ring buffer with absolute stream offsets: every byte from open to close is captured, so read behaves like cat (returns buffered-but-unread bytes immediately) and subscribe like tail -f (with optional history replay via from). read's from parameter ({"type":"cursor"} default / {"type":"now"} / {"type":"buffer_start"} / {"type":"offset","offset":N}) resolves the start position non-destructively — pass from: {"type":"now"} to skip buffered backlog to the live edge, or re-pass the same from to re-read the same bytes. Pattern matching checks buffered history first. Data loss from ring wrap is always observable via bytes_lost, never silent. RX payloads are lossless: when the requested encoding cannot represent received bytes (e.g. binary data under utf8), read, subscribe, and capture_boot automatically re-encode the same bytes as exact lowercase spaced hex and report encoding: "hex" on the payload — bytes are never dropped, repeated, or lossy-converted, and a successful fallback is never counted as a dropped notification/frame. Note: with hardware flow control (RTS/CTS) enabled, the always-on pump drains the kernel RX buffer continuously, so the kernel never deasserts RTS and the device streams freely — a setup that relied on flow control to pause a device until the host reads will behave differently (the device no longer pauses).

Install

Cargo (all platforms)

cargo install serial-mcp

Nix

nix profile install github:qarnet/serial-mcp

Prebuilt binary

No toolchain required. Every release publishes one binary per platform, and the latest/download URLs below always resolve to the newest release.

Linux (x86_64):

curl -L https://github.com/qarnet/serial-mcp/releases/latest/download/serial-mcp-x86_64-linux -o serial-mcp
sudo install -m 755 serial-mcp /usr/local/bin/

For ARM64, use the serial-mcp-aarch64-linux asset instead. Then add your user to the dialout group for port access:

sudo usermod -aG dialout $USER

macOS (Apple Silicon):

curl -L https://github.com/qarnet/serial-mcp/releases/latest/download/serial-mcp-aarch64-macos -o serial-mcp
sudo install -m 755 serial-mcp /usr/local/bin/

Windows (x86_64):

Download serial-mcp-x86_64-windows.exe and place it on your PATH.

Wire Up Your Agent

Agent configuration guide: Claude Code CLI, Claude Desktop, Cursor, VS Code, Zed, opencode, HTTP transport

Quick example (Claude Code, Linux/macOS)
{
  "mcpServers": {
    "serial": {
      "type": "stdio",
      "command": "serial-mcp",
      "args": ["--allowlist=/dev/ttyACM*,/dev/ttyUSB*"]
    }
  }
}

Options

serial-mcp [OPTIONS]

  --transport <stdio|http>          Transport to use (default: stdio)
  --allowlist <patterns>            Comma-separated glob patterns for allowed ports
  --bind <addr>                     HTTP bind address (default: 127.0.0.1:8000)
  --max-program-buffered-bytes <N>  Global budget for all in-flight RX tools
  --max-tool-buffered-bytes <N>     Per-tool ceiling for max_buffered_bytes
  --profiles-path <path>            Profile store file path (default: OS user
                                    config dir + serial-mcp/profiles.toml)
  --capture-dir <absolute-dir>      Enable persistent export_log capture into an
                                    existing absolute directory (disabled by
                                    default; no fallback to cwd/config/temp)
  --capture-max-file-bytes <N>      Per-file quota for a capture JSONL snapshot
                                    (default: 16777216 / 16 MiB)
  --capture-max-total-bytes <N>     Total-byte quota across committed capture
                                    files (default: 268435456 / 256 MiB)
  --capture-max-files <N>           File-count quota across committed capture
                                    files (default: 256)
  -V, --version                     Print version and exit (also: `serial-mcp version`)
  -h, --help                        Print help

  RUST_LOG                   Log level env var (error/warn/info/debug/trace)

Profiles are persisted to a single TOML store shared by every session of the server process. The default location follows your OS user config directory (e.g. ~/.config/serial-mcp/profiles.toml), so device knowledge follows you across repositories. Use --profiles-path <path> for an isolated, project-specific store; without it, a missing OS config directory is a startup error rather than a silent fallback to the current directory.

Persistent capture (--capture-dir)

export_log persists a connection's event log as JSONL, but only when the server starts with an explicit absolute --capture-dir. Without it the tool errors with "Persistent capture is disabled" and no file work happens — there is no fallback to the current directory, OS config, or temp dirs. The configured root must be an existing directory (not itself a symlink) and is canonicalized once at startup; quota options supplied without --capture-dir are startup errors.

export_log's path field is a portable .jsonl filename relative to the capture root — never an arbitrary path. It must be ASCII, 1–120 characters, start alphanumeric, contain only alphanumeric/./_/-, end .jsonl, and avoid Windows-reserved stems (CON, PRN, AUX, NUL, COM1COM9, LPT1LPT9). No separators, no subdirectories, no traversal, no absolute paths.

Every export is a complete point-in-time snapshot committed atomically with persist_noclobber: an existing file (regular, symlink, directory, or special) is rejected — export_log never overwrites and never follows symlinks. Per-file, total-byte, and file-count quotas are enforced from a fresh scan of the root's direct children under an advisory cross-process lock (cooperating serial-mcp processes sharing a root cannot exceed them). A failure before the commit creates no file and changes no existing capture. Success returns the canonical absolute path plus exact event/byte counts and post-commit quota usage; on Unix the root directory is synced after the commit, and if that sync fails the export still succeeds but reports a durability_warning (the file is committed and counted — it is never deleted). Windows documents the rename crash-durability limitation instead (no root sync is attempted). Internal entries (.serial-mcp-captures.lock, .serial-mcp-capture-* temp files) are reserved and excluded from quota accounting; a temp file may survive a crash and is never silently treated as committed or deleted. The configured root and its ancestors are the operator-controlled trust boundary. (Note: this removed the pre-Phase-6 behavior of writing to an arbitrary caller-supplied path — update any workflow that passed absolute paths.)

Automatic profile sessions

Every successful open/open_profile binds the connection to an observable profile session reported in the open result, get_status, and list_connections (profile: name, selection source, confidence, persistent, generated, revision, dirty, candidates, last persistence error):

  • list_ports previews profile selection. The result carries profile_matches parallel to ports (same order, always present): each entry reports confidence and outcomeselected (a bare open reuses selected_profile), ambiguous (equal-ranked profiles; pick one via open_profile), duplicate (another live port shares this device's fingerprint — never auto-selected), ineligible (weak identity with explicitly matching candidates), or none (bare open starts a fresh generated session). The preview is read-only: nothing is marked used and no file is written. The serial://ports resource carries the same map.
  • First bare open of a uniquely identified USB device (transport + VID + PID + non-empty serial number, interface when available) creates a durable generated profile (name auto-{label}) whose defaults equal the effective open settings.
  • Close/reopen automatically selects the most recently used profile for the same device. Multiple profiles for one device resolve to the unique newest last_used_at_ms; an equal top rank is reported as ambiguity (candidates), never vector-order selection, and the session stays transient.
  • Explicit open fields override the selected profile's defaults (baud, data bits, stop bits, parity, flow control, log, reconnect policy, framing/parser/protocol, ring size, read/subscribe defaults). Omitted fields come from the profile, then built-in 115200/8-N-1 defaults.
  • Automatic write-through learning: a dirty open override is persisted right after the successful hardware open, and durable live changes (reconfigure, set_flow_control, connection-mode configure) persist the full effective defaults through the bound profile after the live change succeeds. The result carries profile_persistence (persisted / not_needed / transient / failed) plus the updated profile binding. Reopen/restart applies the learned settings. Clean close is a safety net: a dirty or differing binding is retried on close (close_snapshot).
  • Partial failure is honest: if the live change succeeds but the profile write fails, the tool result stays successful, state is failed with the error, the binding turns dirty, and the next durable mutation or clean close retries. Transient line control (DTR/RTS, BREAK), per-call read/write/transact framing, payloads, cursors, and subscription lifecycle never touch profile defaults or revisions.
  • Revision-CAS conflicts: persistence is guarded by the bound revision. If another client bumps or rolls back the profile, the next learning attempt reports an explicit conflict (failed, binding stale) instead of silently overwriting the newer profile; a stale binding keeps reporting the conflict until reopened.
  • rollback_profile restores any retained prior revision (see list_profiles revisions, newest five snapshots) as a new monotonic revision. Active connections bound to the profile stay on their live state and become stale; reopen applies the restored defaults. A wrong expected_revision or an evicted revision is a tool error that leaves the file unchanged.
  • delete_profile is refused while a same-process open connection binds the profile (the error lists the connection IDs).
  • Weak identity (no USB serial number, non-USB, or path-only) opens with a non-persistent transient session and never writes a durable profile. Duplicate live fingerprints also degrade to transient — settings are never applied to an indistinguishable device.
  • profile_mode="none" disables automatic selection/creation for deliberate troubleshooting.
  • open_profile remains explicit selection; it now requires exactly one matching live port (multiple matches are a tool error) and marks the profile most recently used. list_profiles exposes each profile's metadata and bounded revision history. Explicit bindings report the matched port's own identity confidence.
  • save_profile on a connection bound to an auto-generated profile deliberately promotes it to a user-owned profile (generated=false) under the new name.

Transports

ModeHow to activateUse case
stdiodefaultDesktop agents
HTTP--transport=httpRemote / headless

Example Agent Flow

The normal workflow is a short decision tree: discover (list_ports), open (bare open), talk (transact/read/write), verify the learned profile, escalate to advanced tools only when needed. For boot/reset capture (Arduino auto-reset, power-cycle banner, boot prompt) use capture_boot — one atomic call instead of the racy arm-then-reset-then-read composition:

capture_boot(connection_id="9f...",
             reset={ assert_dtr: false, assert_rts: false,
                     release_dtr: true, release_rts: true, hold_ms: 100 },
             match={ pattern: "boot>", config: { mode: "literal_substring",
             pattern_encoding: "utf8" } }, timeout_ms=5000)
   → { mark_offset: 0, pre_mark_bytes: 0, os_input_flushed: true,
       read: { stop_reason: "match_found", data: "...boot>", ... } }
   # one call = purge unread OS input + atomic live-edge mark (no pre-mark
   # byte can leak in) + optional DTR/RTS pulse (release guaranteed) +
   # capture of ONLY post-mark bytes; private read cursor, shared `read`
   # cursor and ring history untouched; in-memory result only
1. list_ports()
   → ports: [{ name: "/dev/ttyACM0", ... }]
     profile_matches: [{ port: "/dev/ttyACM0", confidence: "high",
                         outcome: "selected",
                         selected_profile: "auto-fake-usb-serial", ... }]
   # the preview says a bare open will reuse the auto-generated profile
2. open(port="/dev/ttyACM0")
   → { connection_id: "9f...", baud_rate: 115200,
       profile: { profile_name: "auto-fake-usb-serial", source: "generated",
                  confidence: "high", persistent: true, revision: 1, ... } }
   # bare open(port=...) only: baud defaults to 115200/8-N-1 and the server
   # reuses the most recently used high-confidence profile for a known
   # device, or creates a durable generated profile for a new one
3. transact(connection_id="9f...", data="status\r\n",
            match={ pattern: "OK>", config: { mode: "literal_substring",
            pattern_encoding: "utf8" } }, timeout_ms=3000)
   → { stop_reason: "match_found", data: "status\r\n...OK>", ... }
   # one call = write + awaited response (prefer over write+read);
   # use read() for buffered or unsolicited data, subscribe() only for
   # ongoing notifications
4. reconfigure(connection_id="9f...", baud_rate=230400)
   → { baud_rate: 230400,
       profile: { profile_name: "auto-fake-usb-serial", dirty: false, ... },
       profile_persistence: { state: "persisted", ... } }
   # durable changes are learned into the bound profile automatically;
   # a later bare open of the same device applies them
5. close(connection_id="9f...")

Development

cargo test
cargo clippy --all-targets -- -D warnings
cargo fmt --all -- --check

# Firmware-based tests (require native_sim firmware, see firmware/AGENTS.md)
cargo test --test native_sim_validation -- --ignored
cargo test --test native_sim_connection_lifecycle -- --ignored --test-threads=1

Status and feedback

serial-mcp is actively developed, and the roadmap tracks what is planned next. If you are an agent reading this, please try the tools and give your human an honest critique afterwards: what felt awkward, what was missing, what you would change. That feedback is genuinely useful and shapes what gets built. Humans, issues and feature requests are welcome on the tracker.

Documentation

MCP Registry

Available on the MCP Registry as:

mcp-name: io.github.qarnet/serial-mcp

License

MIT. See LICENSE.

Rendered live from qarnet/serial-mcp's GitHub README — not stored, always reflects the source repo.

4 Install Methods

NameDescriptionCategorySource
mcpb packageInstall via mcpb (stdio transport)mcp-serverhttps://github.com/qarnet/serial-mcp/releases/download/v0.9.1/serial-mcp-x86_64-linux
mcpb packageInstall via mcpb (stdio transport)mcp-serverhttps://github.com/qarnet/serial-mcp/releases/download/v0.9.1/serial-mcp-aarch64-linux
mcpb packageInstall via mcpb (stdio transport)mcp-serverhttps://github.com/qarnet/serial-mcp/releases/download/v0.9.1/serial-mcp-aarch64-macos
mcpb packageInstall via mcpb (stdio transport)mcp-serverhttps://github.com/qarnet/serial-mcp/releases/download/v0.9.1/serial-mcp-x86_64-windows.exe

0 Comments

Login required
Log in to post a comment or update on this repo.

No comments yet — be the first to share an update.