feat: add OOT module directory as MCP resources

Curated catalog of 20 GNU Radio OOT modules served via two MCP
resources (oot://directory, oot://directory/{name}).  Each entry
includes git URL, branch, build deps, and a ready-to-use
install_oot_module() example.

Modules are tagged preinstalled when they ship with the
gnuradio-runtime base Docker image (12 of 20), so agents can
distinguish what's already available from what needs building.
This commit is contained in:
Ryan Malloy 2026-01-31 14:43:55 -07:00
parent 61471b7280
commit 521c306173
3 changed files with 564 additions and 0 deletions

View File

@ -0,0 +1,300 @@
"""Curated catalog of GNU Radio OOT modules.
Provides browsable metadata so MCP clients can discover available
modules and get the exact parameters needed for install_oot_module()
without guessing URLs or build dependencies.
Modules marked ``preinstalled=True`` ship with the gnuradio-runtime
base Docker image via Debian packages. They can still be rebuilt
from source (e.g., to get a newer version) via install_oot_module().
"""
from __future__ import annotations
from pydantic import BaseModel
# ──────────────────────────────────────────────
# Data Models
# ──────────────────────────────────────────────
class OOTModuleEntry(BaseModel):
"""A curated OOT module in the directory."""
name: str
description: str
category: str
git_url: str
branch: str = "main"
build_deps: list[str] = []
cmake_args: list[str] = []
homepage: str = ""
gr_versions: str = "3.10+"
preinstalled: bool = False
class OOTModuleSummary(BaseModel):
"""Compact entry for the directory index."""
name: str
description: str
category: str
preinstalled: bool = False
installed: bool | None = None
class OOTDirectoryIndex(BaseModel):
"""Response shape for oot://directory."""
modules: list[OOTModuleSummary]
count: int
class OOTModuleDetail(BaseModel):
"""Response shape for oot://directory/{name}."""
name: str
description: str
category: str
git_url: str
branch: str
build_deps: list[str]
cmake_args: list[str]
homepage: str
gr_versions: str
preinstalled: bool = False
installed: bool | None = None
installed_image_tag: str | None = None
install_example: str = ""
# ──────────────────────────────────────────────
# Catalog Entries
# ──────────────────────────────────────────────
def _entry(
name: str,
description: str,
category: str,
git_url: str,
branch: str = "main",
build_deps: list[str] | None = None,
cmake_args: list[str] | None = None,
homepage: str = "",
gr_versions: str = "3.10+",
preinstalled: bool = False,
) -> OOTModuleEntry:
return OOTModuleEntry(
name=name,
description=description,
category=category,
git_url=git_url,
branch=branch,
build_deps=build_deps or [],
cmake_args=cmake_args or [],
homepage=homepage,
gr_versions=gr_versions,
preinstalled=preinstalled,
)
CATALOG: dict[str, OOTModuleEntry] = {
e.name: e
for e in [
# ── Pre-installed in gnuradio-runtime base image ──
_entry(
name="osmosdr",
description="Hardware source/sink for RTL-SDR, Airspy, HackRF, and more",
category="Hardware",
git_url="https://github.com/osmocom/gr-osmosdr",
branch="master",
build_deps=["librtlsdr-dev", "libairspy-dev", "libhackrf-dev"],
homepage="https://osmocom.org/projects/gr-osmosdr/wiki",
preinstalled=True,
),
_entry(
name="satellites",
description="Satellite telemetry decoders (AX.25, CCSDS, AO-73, etc.)",
category="Satellite",
git_url="https://github.com/daniestevez/gr-satellites",
branch="main",
build_deps=["python3-construct", "python3-requests"],
homepage="https://gr-satellites.readthedocs.io/",
preinstalled=True,
),
_entry(
name="gsm",
description="GSM/GPRS burst receiver and channel decoder",
category="Cellular",
git_url="https://github.com/ptrkrysik/gr-gsm",
branch="master",
build_deps=["libosmocore-dev"],
homepage="https://github.com/ptrkrysik/gr-gsm",
preinstalled=True,
),
_entry(
name="rds",
description="FM RDS/RBDS (Radio Data System) decoder",
category="Broadcast",
git_url="https://github.com/bastibl/gr-rds",
branch="main",
homepage="https://github.com/bastibl/gr-rds",
preinstalled=True,
),
_entry(
name="fosphor",
description="GPU-accelerated real-time spectrum display (OpenCL)",
category="Visualization",
git_url="https://github.com/osmocom/gr-fosphor",
branch="master",
build_deps=["libfreetype6-dev", "ocl-icd-opencl-dev"],
homepage="https://osmocom.org/projects/sdr/wiki/Fosphor",
preinstalled=True,
),
_entry(
name="air_modes",
description="Mode-S/ADS-B aircraft transponder decoder (1090 MHz)",
category="Aviation",
git_url="https://github.com/bistromath/gr-air-modes",
branch="master",
homepage="https://github.com/bistromath/gr-air-modes",
preinstalled=True,
),
_entry(
name="funcube",
description="Funcube Dongle Pro/Pro+ controller and source block",
category="Hardware",
git_url="https://github.com/dl1ksv/gr-funcube",
branch="master",
homepage="https://github.com/dl1ksv/gr-funcube",
preinstalled=True,
),
_entry(
name="hpsdr",
description="OpenHPSDR Protocol 1 interface for HPSDR hardware",
category="Hardware",
git_url="https://github.com/Tom-McDermott/gr-hpsdr",
branch="master",
homepage="https://github.com/Tom-McDermott/gr-hpsdr",
preinstalled=True,
),
_entry(
name="iqbal",
description="Blind IQ imbalance estimator and correction",
category="Analysis",
git_url="https://github.com/osmocom/gr-iqbal",
branch="master",
homepage="https://git.osmocom.org/gr-iqbal",
preinstalled=True,
),
_entry(
name="limesdr",
description="LimeSDR source/sink blocks (LMS7002M)",
category="Hardware",
git_url="https://github.com/myriadrf/gr-limesdr",
branch="master",
homepage="https://wiki.myriadrf.org/Gr-limesdr_Plugin_for_GNURadio",
preinstalled=True,
),
_entry(
name="radar",
description="Radar signal processing toolbox (FMCW, OFDM radar)",
category="Analysis",
git_url="https://github.com/kit-cel/gr-radar",
branch="master",
homepage="https://github.com/kit-cel/gr-radar",
preinstalled=True,
),
_entry(
name="satnogs",
description="SatNOGS satellite ground station decoders and deframers",
category="Satellite",
git_url="https://gitlab.com/librespacefoundation/satnogs/gr-satnogs",
branch="master",
homepage="https://gitlab.com/librespacefoundation/satnogs/gr-satnogs",
preinstalled=True,
),
# ── Installable via install_oot_module ──
_entry(
name="lora_sdr",
description="LoRa PHY transceiver (CSS modulation/demodulation)",
category="IoT",
git_url="https://github.com/tapparelj/gr-lora_sdr",
branch="master",
homepage="https://github.com/tapparelj/gr-lora_sdr",
),
_entry(
name="ieee802_11",
description="IEEE 802.11a/g/p OFDM transceiver",
category="WiFi",
git_url="https://github.com/bastibl/gr-ieee802-11",
branch="main",
homepage="https://github.com/bastibl/gr-ieee802-11",
),
_entry(
name="ieee802_15_4",
description="IEEE 802.15.4 (Zigbee) O-QPSK transceiver",
category="IoT",
git_url="https://github.com/bastibl/gr-ieee802-15-4",
branch="main",
homepage="https://github.com/bastibl/gr-ieee802-15-4",
),
_entry(
name="adsb",
description="ADS-B (1090 MHz) aircraft transponder decoder",
category="Aviation",
git_url="https://github.com/mhostetter/gr-adsb",
branch="main",
homepage="https://github.com/mhostetter/gr-adsb",
),
_entry(
name="iridium",
description="Iridium satellite burst detector and demodulator",
category="Satellite",
git_url="https://github.com/muccc/gr-iridium",
branch="main",
homepage="https://github.com/muccc/gr-iridium",
),
_entry(
name="inspector",
description="Signal analysis toolbox (energy detection, OFDM estimation)",
category="Analysis",
git_url="https://github.com/gnuradio/gr-inspector",
branch="master",
homepage="https://github.com/gnuradio/gr-inspector",
),
_entry(
name="nrsc5",
description="HD Radio (NRSC-5) digital broadcast decoder",
category="Broadcast",
git_url="https://github.com/argilo/gr-nrsc5",
branch="main",
homepage="https://github.com/argilo/gr-nrsc5",
),
_entry(
name="packet_radio",
description="Amateur packet radio (AFSK, AX.25) modem",
category="Amateur",
git_url="https://github.com/duggabe/gr-packet-radio",
branch="main",
homepage="https://github.com/duggabe/gr-packet-radio",
),
]
}
def build_install_example(entry: OOTModuleEntry) -> str:
"""Format a copy-paste install_oot_module() call for this module."""
parts = [f'install_oot_module(git_url="{entry.git_url}"']
if entry.branch != "main":
parts.append(f', branch="{entry.branch}"')
if entry.build_deps:
deps = ", ".join(f'"{d}"' for d in entry.build_deps)
parts.append(f", build_deps=[{deps}]")
if entry.cmake_args:
args = ", ".join(f'"{a}"' for a in entry.cmake_args)
parts.append(f", cmake_args=[{args}]")
parts.append(")")
return "".join(parts)

View File

@ -23,6 +23,7 @@ class McpRuntimeProvider:
self._mcp = mcp_instance
self._provider = runtime_provider
self.__init_tools()
self.__init_resources()
def __init_tools(self):
p = self._provider
@ -86,6 +87,84 @@ class McpRuntimeProvider:
"container tools skipped)"
)
def __init_resources(self):
from gnuradio_mcp.oot_catalog import (
CATALOG,
OOTDirectoryIndex,
OOTModuleDetail,
OOTModuleSummary,
build_install_example,
)
oot_mw = self._provider._oot # None when Docker unavailable
@self._mcp.resource(
"oot://directory",
name="oot_directory",
description="Index of curated GNU Radio OOT modules available for installation",
mime_type="application/json",
)
def list_oot_directory() -> str:
summaries = []
for entry in CATALOG.values():
installed = None
if oot_mw is not None:
installed = entry.name in oot_mw._registry
summaries.append(
OOTModuleSummary(
name=entry.name,
description=entry.description,
category=entry.category,
preinstalled=entry.preinstalled,
installed=installed,
)
)
index = OOTDirectoryIndex(modules=summaries, count=len(summaries))
return index.model_dump_json()
@self._mcp.resource(
"oot://directory/{module_name}",
name="oot_module_detail",
description="Full installation details for a specific OOT module",
mime_type="application/json",
)
def get_oot_module(module_name: str) -> str:
entry = CATALOG.get(module_name)
if entry is None:
known = ", ".join(sorted(CATALOG.keys()))
raise ValueError(
f"Unknown module '{module_name}'. Available: {known}"
)
installed = None
installed_image_tag = None
if oot_mw is not None:
info = oot_mw._registry.get(entry.name)
installed = info is not None
if info is not None:
installed_image_tag = info.image_tag
detail = OOTModuleDetail(
name=entry.name,
description=entry.description,
category=entry.category,
git_url=entry.git_url,
branch=entry.branch,
build_deps=entry.build_deps,
cmake_args=entry.cmake_args,
homepage=entry.homepage,
gr_versions=entry.gr_versions,
preinstalled=entry.preinstalled,
installed=installed,
installed_image_tag=installed_image_tag,
install_example=build_install_example(entry),
)
return detail.model_dump_json()
logger.info(
"Registered OOT directory resources (%d modules)", len(CATALOG)
)
@classmethod
def create(cls, mcp_instance: FastMCP) -> McpRuntimeProvider:
"""Factory: create RuntimeProvider with optional Docker support."""

View File

@ -0,0 +1,185 @@
"""Tests for the OOT module catalog and its data models."""
import json
import pytest
from gnuradio_mcp.oot_catalog import (
CATALOG,
OOTDirectoryIndex,
OOTModuleDetail,
OOTModuleEntry,
OOTModuleSummary,
build_install_example,
)
class TestCatalogIntegrity:
def test_catalog_has_entries(self):
assert len(CATALOG) >= 15
def test_all_entries_have_git_url(self):
for name, entry in CATALOG.items():
assert entry.git_url.startswith("https://"), (
f"{name}: git_url must start with https://"
)
def test_module_names_unique(self):
names = [e.name for e in CATALOG.values()]
assert len(names) == len(set(names))
def test_all_categories_nonempty(self):
for name, entry in CATALOG.items():
assert entry.category, f"{name}: category must not be empty"
def test_all_entries_have_description(self):
for name, entry in CATALOG.items():
assert entry.description, f"{name}: description must not be empty"
def test_catalog_keys_match_entry_names(self):
for key, entry in CATALOG.items():
assert key == entry.name, (
f"Key '{key}' does not match entry name '{entry.name}'"
)
def test_unknown_module_not_in_catalog(self):
assert CATALOG.get("nonexistent") is None
def test_has_preinstalled_modules(self):
preinstalled = [e for e in CATALOG.values() if e.preinstalled]
assert len(preinstalled) >= 5
def test_has_installable_modules(self):
installable = [e for e in CATALOG.values() if not e.preinstalled]
assert len(installable) >= 5
def test_known_preinstalled_modules(self):
expected = {"osmosdr", "satellites", "gsm", "rds", "fosphor"}
preinstalled_names = {
e.name for e in CATALOG.values() if e.preinstalled
}
assert expected.issubset(preinstalled_names)
def test_known_installable_modules(self):
expected = {"lora_sdr", "ieee802_11", "adsb", "iridium"}
installable_names = {
e.name for e in CATALOG.values() if not e.preinstalled
}
assert expected.issubset(installable_names)
class TestModels:
def test_summary_round_trip(self):
summary = OOTModuleSummary(
name="test_mod",
description="A test module",
category="Testing",
preinstalled=True,
installed=True,
)
data = json.loads(summary.model_dump_json())
restored = OOTModuleSummary(**data)
assert restored.name == "test_mod"
assert restored.preinstalled is True
assert restored.installed is True
def test_summary_installed_default_none(self):
summary = OOTModuleSummary(
name="x", description="y", category="z"
)
assert summary.installed is None
assert summary.preinstalled is False
def test_detail_includes_install_fields(self):
detail = OOTModuleDetail(
name="lora_sdr",
description="LoRa",
category="IoT",
git_url="https://github.com/tapparelj/gr-lora_sdr",
branch="master",
build_deps=[],
cmake_args=[],
homepage="",
gr_versions="3.10+",
)
data = detail.model_dump()
assert "git_url" in data
assert "branch" in data
assert "build_deps" in data
assert "install_example" in data
assert "preinstalled" in data
def test_detail_preinstalled_flag(self):
detail = OOTModuleDetail(
name="osmosdr",
description="HW",
category="Hardware",
git_url="https://example.com",
branch="master",
build_deps=[],
cmake_args=[],
homepage="",
gr_versions="3.10+",
preinstalled=True,
)
assert detail.preinstalled is True
def test_directory_index_count(self):
summaries = [
OOTModuleSummary(name="a", description="A", category="X"),
OOTModuleSummary(
name="b", description="B", category="Y", preinstalled=True
),
]
index = OOTDirectoryIndex(modules=summaries, count=2)
assert index.count == 2
assert len(index.modules) == 2
assert index.modules[1].preinstalled is True
class TestBuildInstallExample:
def test_simple_module(self):
entry = OOTModuleEntry(
name="adsb",
description="ADS-B decoder",
category="Aviation",
git_url="https://github.com/mhostetter/gr-adsb",
branch="main",
)
example = build_install_example(entry)
assert "git_url=" in example
assert "gr-adsb" in example
# branch=main is the default, should not appear
assert "branch=" not in example
def test_non_default_branch(self):
entry = OOTModuleEntry(
name="lora_sdr",
description="LoRa",
category="IoT",
git_url="https://github.com/tapparelj/gr-lora_sdr",
branch="master",
)
example = build_install_example(entry)
assert 'branch="master"' in example
def test_with_build_deps(self):
entry = OOTModuleEntry(
name="osmosdr",
description="HW source/sink",
category="Hardware",
git_url="https://github.com/osmocom/gr-osmosdr",
branch="master",
build_deps=["librtlsdr-dev", "libairspy-dev"],
)
example = build_install_example(entry)
assert "build_deps=" in example
assert "librtlsdr-dev" in example
def test_all_catalog_entries_produce_example(self):
for name, entry in CATALOG.items():
example = build_install_example(entry)
assert example.startswith("install_oot_module("), (
f"{name}: bad install example"
)
assert example.endswith(")")