From 521c30617350aa2f5878be197260780959097f86 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sat, 31 Jan 2026 14:43:55 -0700 Subject: [PATCH] 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. --- src/gnuradio_mcp/oot_catalog.py | 300 ++++++++++++++++++++++ src/gnuradio_mcp/providers/mcp_runtime.py | 79 ++++++ tests/unit/test_oot_catalog.py | 185 +++++++++++++ 3 files changed, 564 insertions(+) create mode 100644 src/gnuradio_mcp/oot_catalog.py create mode 100644 tests/unit/test_oot_catalog.py diff --git a/src/gnuradio_mcp/oot_catalog.py b/src/gnuradio_mcp/oot_catalog.py new file mode 100644 index 0000000..80ba6fa --- /dev/null +++ b/src/gnuradio_mcp/oot_catalog.py @@ -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) diff --git a/src/gnuradio_mcp/providers/mcp_runtime.py b/src/gnuradio_mcp/providers/mcp_runtime.py index e0b41b1..4d09e1e 100644 --- a/src/gnuradio_mcp/providers/mcp_runtime.py +++ b/src/gnuradio_mcp/providers/mcp_runtime.py @@ -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.""" diff --git a/tests/unit/test_oot_catalog.py b/tests/unit/test_oot_catalog.py new file mode 100644 index 0000000..6b91d60 --- /dev/null +++ b/tests/unit/test_oot_catalog.py @@ -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(")")