Containerized Claude Code + MCP server + A2A server driving a Gqrx-based SDR receiver. Supports multiple SDRs simultaneously (one Gqrx process per device, each on its own rigctl port), fans tool calls out across them, and exposes the whole thing as an A2A-compliant agent so other agents can call in.
┌──────────────────────── Docker container ────────────────────────┐
│ │
│ claude (CLI) MCP server (HTTP) A2A server │
│ │ │ │ │
│ └──── /mcp on :8765 ───┘ │ │
│ │ │ │
│ DeviceManager ← singleton ───┘ │
│ │ │
│ ┌──────────────────┼──────────────────┐ │
│ ▼ ▼ ▼ │
│ Gqrx (dev 0) Gqrx (dev 1) Gqrx (dev N) │
│ rigctl :7356 rigctl :7357 rigctl :7356+N │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ RTL-SDR #0 RTL-SDR #1 HackRF, etc. │
│ (via USB passthrough from host) │
│ │
│ Audio → host PulseAudio socket (bind mount) ──────────→ host │
└──────────────────────────────────────────────────────────────────┘
Anything SoapySDR enumerates, because Gqrx's input string uses the SoapySDR
form (soapy=0,driver=<drv>,serial=<sn>). Out of the box:
- RTL-SDR (including RTL-SDR Blog V3/V4, Nooelec NESDR)
- HackRF
- Airspy / Airspy HF+
- BladeRF
- LimeSDR
- PlutoSDR
- SDRplay (requires proprietary API — not installed; add if you own one)
- UHD-based (USRP)
- Miri, Osmo, RedPitaya, XTRX, RFSpace, FreeSRP
Live-tested here with 2× RTL-SDR (Nooelec NESDR SMArt v5 and RTL-SDR Blog V4).
-
Docker + Docker Compose.
-
SDR hardware plugged in. No udev rules needed — the container runs as root and the USB bus is bind-mounted.
-
If you have a DVB-T tuner kernel module (
dvb_usb_rtl28xxu,rtl2832) claiming your RTL-SDR, unload it:sudo rmmod dvb_usb_rtl28xxu || true # persist across reboots: echo 'blacklist dvb_usb_rtl28xxu' | sudo tee /etc/modprobe.d/blacklist-rtlsdr.conf
Check first with
lsmod | grep -E 'rtl|dvb'— if nothing prints, your dongles are already free.
cd qgrs_mcp_agent
cp .env.example .env # optional: set ANTHROPIC_API_KEY or A2A_PUBLIC_URL
docker compose build
docker compose run --rm --service-ports qgrx-agentThat drops you into an interactive claude shell with the qgrx MCP server
already registered. The first detected SDR is auto-started; additional ones
come up on demand via start_device.
Inside Claude, try:
- "List the SDR devices"
- "Start all devices, tune device 0 to FM 101.1 and device 1 to UHF 446 MHz"
- "Scan 88 to 108 MHz, splitting the range across all running devices"
- "What's the signal strength on every device right now?"
Exit with /exit. The container stops and kills the Gqrx processes cleanly.
If you want the MCP + A2A servers without the interactive claude shell:
docker compose run --rm --service-ports qgrx-agent bash
# or run a specific command:
docker compose run --rm --service-ports qgrx-agent python3 -m qgrx_agent.mcp_serverThe MCP server listens on http://localhost:8765/mcp (Streamable HTTP
transport). All tools return JSON strings.
| Tool | Args | Purpose |
|---|---|---|
list_devices |
– | Enumerate all registered SDRs with state (running, active, rigctl port, tuner, serial). |
rediscover_devices |
– | Re-probe SoapySDR to pick up newly-plugged hardware without disturbing running devices. |
start_device |
device |
Launch Gqrx for one SDR and start DSP (AOS). Idempotent. |
stop_device |
device |
Stop the Gqrx instance for one SDR. |
start_all_devices |
– | Launch Gqrx for every registered SDR. |
stop_all_devices |
– | Stop every running Gqrx instance. |
select_device |
device |
Change which device is "active" (used when tools omit the device arg). |
get_active_device |
– | Return the currently active device. |
device accepts either the numeric id from list_devices (e.g. 0, 1)
or the SDR's serial string. If omitted, the active device is used.
Each accepts an optional device argument (default: active device):
set_frequency(hz)/get_frequency()set_demodulator(mode, passband_hz=0)— modes:OFF,RAW,AM,AMS,FM,WFM,WFM_ST,WFM_ST_OIRT,LSB,USB,CW,CWL,CWUget_demodulator()get_signal_strength()— dBFSset_squelch(dbfs)/get_squelch()start_recording()/stop_recording()— WAV files under/root/gqrx(mapped to./recordings/on host)start_receiver()/stop_receiver()— rigctl AOS / LOSstatus()— snapshot of frequency, mode, squelch, signal strengthscan_range(start_hz, end_hz, step_hz=100_000, dwell_ms=200)— sweep one device
set_frequency_all(hz)— tune every running device to the same frequencyset_demodulator_all(mode, passband_hz=0)get_signal_strength_all(hz=None)— optionally retune first, then sample allstatus_all()— snapshot from every running devicestart_receiver_all()/stop_receiver_all()scan_range_split(start_hz, end_hz, step_hz=100_000, dwell_ms=200)— split the range across all running devices, sweep slices in parallel. Roughly N× faster thanscan_rangewith N SDRs.
The A2A server exposes the agent to any A2A-compliant client, spec version
0.2.1.
- Agent card:
GET http://localhost:9090/.well-known/agent-card.json - JSON-RPC endpoint:
POST http://localhost:9090/with methodsmessage/send,tasks/get,tasks/cancel.
Example:
curl -s http://localhost:9090/.well-known/agent-card.json | jq .
curl -s -X POST http://localhost:9090/ \
-H 'content-type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"role": "user",
"parts": [{"kind": "text", "text": "tune 101100000"}],
"messageId": "abc"
}
}
}' | jq .A2A commands: tune <hz>, status, start, stop, demod <mode> [passband],
scan <start> <end> [step]. The A2A server currently operates on the first
device (rigctl port 7356); if you need per-device routing via A2A, extend
qgrx_agent/a2a_server.py::_dispatch.
| Var | Default | Purpose |
|---|---|---|
ANTHROPIC_API_KEY |
– | Claude Code API auth if you're not mounting ~/.claude. |
A2A_PUBLIC_URL |
http://localhost:9090/ |
URL advertised on the agent card. Set this if the container is reachable from other hosts. |
MCP_HOST / MCP_PORT |
0.0.0.0 / 8765 |
MCP server bind. |
A2A_HOST / A2A_PORT |
0.0.0.0 / 9090 |
A2A server bind. |
GQRX_RIGCTL_PORT |
7356 |
Base rigctl port. Device N gets 7356 + N. |
QGRX_CONFIG_DIR |
/root/.config/gqrx |
Where per-device Gqrx configs are written. |
QGRX_AUTOSTART_FIRST |
1 |
Set to 0 to skip auto-starting the first SDR on MCP server boot. |
| Port | Service |
|---|---|
| 7356 | Gqrx rigctl (device 0) — raw TCP, rigctl protocol |
| 8765 | MCP server (Streamable HTTP) |
| 9090 | A2A server (JSON-RPC) |
Additional rigctl ports (7357+) for extra devices are reachable inside the
container but not exposed externally. Add "7357:7357" to
docker-compose.yml if you need them.
| Host path | Container path | Purpose |
|---|---|---|
/dev/bus/usb |
/dev/bus/usb |
USB passthrough for SDR dongles |
$XDG_RUNTIME_DIR/pulse |
/run/pulse |
Host PulseAudio socket (read-only) |
~/.config/pulse/cookie |
/root/.config/pulse/cookie |
PulseAudio auth cookie |
~/.claude |
/root/.claude |
Reuse Claude Code host credentials |
./recordings |
/root/gqrx |
Persistent location for start_recording output |
qgrs_mcp_agent/
├── Dockerfile Ubuntu 24.04 + gqrx + soapysdr-module-all + Node + Claude Code
├── docker-compose.yml USB / audio / creds passthrough, port mapping
├── pyproject.toml Python package definition (mcp, starlette, uvicorn)
├── qgrx_agent/
│ ├── gqrx_client.py Async rigctl TCP client
│ ├── sdr_probe.py SoapySDR device enumeration
│ ├── device_manager.py Multi-instance Gqrx lifecycle + fan-out helpers
│ ├── mcp_server.py FastMCP (Streamable HTTP) tool surface
│ └── a2a_server.py A2A JSON-RPC server + agent card
├── scripts/
│ └── entrypoint.sh Xvfb + audio bootstrap + server launch + MCP register
├── .env.example
├── .gitignore
├── .dockerignore
└── README.md
rtl_sdr: usb_open error -3 / usb_claim_interface error -6 — the DVB-T
kernel driver is holding your dongle. See the host prerequisites section.
rigctl did not come up on port 7356 — Gqrx crashed during startup.
Check /tmp/gqrx-<id>.log inside the container:
docker compose exec qgrx-agent tail -50 /tmp/gqrx-0.logMost common causes: PulseAudio not reachable (shouldn't happen with the
entrypoint's fallback daemon), device already in use by another process on
the host, or [remote_control] enabled=true missing from the config.
No audio on host — confirm the PulseAudio socket is being mounted:
echo $XDG_RUNTIME_DIR # usually /run/user/1000
ls $XDG_RUNTIME_DIR/pulse/native # should be a socket file
docker compose exec qgrx-agent pactl infoIf pactl info inside the container says "Connection refused", the mount
isn't reaching it. On PipeWire systems, make sure pipewire-pulse is
running on the host.
MCP server not registered in Claude Code — the entrypoint runs
claude mcp add qgrx --transport http http://127.0.0.1:8765/mcp on
startup. If that failed (e.g. the CLI wasn't authenticated yet), re-run it
manually inside the container:
docker compose exec qgrx-agent claude mcp add qgrx --transport http http://127.0.0.1:8765/mcpSignal strength always reads −200 dBFS — that's Gqrx's floor value when
the receiver isn't running or there's no antenna. Call start_receiver (or
start_device, which sends AOS on startup) and make sure an antenna is
connected to the dongle.
A new SDR plugged in isn't showing up — the device list is built at
MCP server startup. Call rediscover_devices to re-probe without restarting.