Skip to content

probsJustin/qgrs_mcp_agent

Repository files navigation

qgrx-agent

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    │
└──────────────────────────────────────────────────────────────────┘

Supported SDRs

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).

Host prerequisites

  1. Docker + Docker Compose.

  2. SDR hardware plugged in. No udev rules needed — the container runs as root and the USB bus is bind-mounted.

  3. 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.

Quickstart

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-agent

That 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.

Without Claude Code

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_server

MCP tool reference

The MCP server listens on http://localhost:8765/mcp (Streamable HTTP transport). All tools return JSON strings.

Device management

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.

Single-device controls

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, CWU
  • get_demodulator()
  • get_signal_strength() — dBFS
  • set_squelch(dbfs) / get_squelch()
  • start_recording() / stop_recording() — WAV files under /root/gqrx (mapped to ./recordings/ on host)
  • start_receiver() / stop_receiver() — rigctl AOS / LOS
  • status() — snapshot of frequency, mode, squelch, signal strength
  • scan_range(start_hz, end_hz, step_hz=100_000, dwell_ms=200) — sweep one device

Multi-device controls (parallel fan-out)

  • set_frequency_all(hz) — tune every running device to the same frequency
  • set_demodulator_all(mode, passband_hz=0)
  • get_signal_strength_all(hz=None) — optionally retune first, then sample all
  • status_all() — snapshot from every running device
  • start_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 than scan_range with N SDRs.

A2A (Agent-to-Agent)

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 methods message/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.

Configuration

Environment variables

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.

Exposed ports

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.

Volumes

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

File layout

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

Troubleshooting

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.log

Most 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 info

If 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/mcp

Signal 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.

About

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.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors