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:
parent
61471b7280
commit
521c306173
300
src/gnuradio_mcp/oot_catalog.py
Normal file
300
src/gnuradio_mcp/oot_catalog.py
Normal 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)
|
||||
@ -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."""
|
||||
|
||||
185
tests/unit/test_oot_catalog.py
Normal file
185
tests/unit/test_oot_catalog.py
Normal 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(")")
|
||||
Loading…
x
Reference in New Issue
Block a user