feat: add generate_grc_yaml tool for GNU Radio Companion block definitions

Blocks generated by gr-mcp now have a first-class path to GRC's
graphical editor. Previously, .block.yml was only produced as a
side-effect of full OOT export — now clients can generate YAML
standalone to preview, customize, or use without a full OOT module.

- New MCP tool: generate_grc_yaml (registered in block dev mode)
- New model: GrcYamlResult with yaml_content, notes, filename
- Enhanced YAML generation: flags, asserts from min/max bounds,
  parameter descriptions, docstring extraction
- New prompt resource: prompts://block-generation/grc-yaml
- 10 new unit tests for GRC YAML generation (475 total, 7 skipped)
This commit is contained in:
Ryan Malloy 2026-02-24 09:47:26 -07:00
parent 3fe862109d
commit ce785029f4
7 changed files with 446 additions and 36 deletions

View File

@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any
from gnuradio_mcp.models import (
GeneratedBlockCode,
GrcYamlResult,
OOTExportResult,
OOTSkeletonResult,
)
@ -146,15 +147,15 @@ from importlib import import_module
{imports}
'''
BLOCK_YML_TEMPLATE = '''id: {module_name}_{block_name}
BLOCK_YML_TEMPLATE = '''id: {block_id}
label: {block_label}
category: [{module_name}]
category: [{category}]
flags: [ python ]
templates:
imports: from {module_name} import {block_name}
make: {module_name}.{block_name}({make_args})
imports: {import_line}
make: {make_line}
{callbacks}
parameters:
{parameters}
@ -163,7 +164,7 @@ inputs:
outputs:
{outputs}
{asserts}
documentation: |-
{documentation}
@ -410,7 +411,7 @@ class OOTExporterMiddleware:
# Generate and write .block.yml
grc_dir = base_path / "grc"
yml_content = self._generate_block_yml(
generated, module_name, block_name
generated, module_name, block_name, None
)
yml_file = grc_dir / f"{module_name}_{block_name}.block.yml"
yml_file.write_text(yml_content)
@ -513,70 +514,144 @@ class OOTExporterMiddleware:
# Block YAML Generation
# ──────────────────────────────────────────
def generate_grc_yaml(
self,
generated: GeneratedBlockCode,
module_name: str = "custom",
category: str | None = None,
) -> GrcYamlResult:
"""Generate a GRC YAML block definition (.block.yml).
Produces a YAML file that makes a Python block usable in
GNU Radio Companion's graphical editor. Without this file,
the block can only be used programmatically.
Args:
generated: Block code from generate_*_block tools
module_name: Module namespace (e.g., "custom" id becomes "custom_blockname")
category: GRC palette category (default: module_name)
Returns:
GrcYamlResult with YAML content and metadata.
"""
module_name = self._sanitize_module_name(module_name)
block_name = self._sanitize_block_name(generated.block_name)
try:
yaml_content = self._generate_block_yml(
generated, module_name, block_name, category
)
block_id = f"{module_name}_{block_name}"
filename = f"{block_id}.block.yml"
notes = []
if not generated.parameters:
notes.append("No parameters defined — block has no configurable properties in GRC.")
if not any(
m for m in self._parse_block_source(generated.source_code).get("callbacks", [])
):
notes.append(
"No set_*() methods found — parameters won't update at runtime. "
"Add setter methods to enable GRC callback support."
)
return GrcYamlResult(
success=True,
block_id=block_id,
yaml_content=yaml_content,
filename=filename,
notes=notes,
)
except Exception as e:
logger.exception("Failed to generate GRC YAML")
return GrcYamlResult(
success=False,
error=str(e),
)
def _generate_block_yml(
self,
generated: GeneratedBlockCode,
module_name: str,
block_name: str,
category: str | None = None,
) -> str:
"""Generate .block.yml content for a block."""
# Parse source to extract info
block_info = self._parse_block_source(generated.source_code)
# Build label
block_id = f"{module_name}_{block_name}"
block_label = block_name.replace("_", " ").title()
cat = category or module_name
# Build make args
# Import and make lines
import_line = f"from {module_name} import {block_name}"
make_args = ", ".join([
f"{p.name}=${{{p.name}}}" for p in generated.parameters
]) if generated.parameters else ""
make_line = f"{module_name}.{block_name}({make_args})"
# Build callbacks section
# Callbacks from detected setter methods
callbacks = ""
if block_info.get("callbacks"):
callbacks = "callbacks:\n" + "\n".join([
cb_lines = "\n".join([
f" - set_{name}(${{{name}}})" for name in block_info["callbacks"]
])
callbacks = f" callbacks:\n{cb_lines}\n"
# Build parameters section
# Parameters
params_yml = ""
for p in generated.parameters:
params_yml += f"""- id: {p.name}
label: {p.name.replace('_', ' ').title()}
dtype: {self._python_to_grc_dtype(p.dtype)}
default: {repr(p.default)}
"""
grc_dtype = self._python_to_grc_dtype(p.dtype)
params_yml += f"- id: {p.name}\n"
params_yml += f" label: {p.name.replace('_', ' ').title()}\n"
params_yml += f" dtype: {grc_dtype}\n"
params_yml += f" default: {repr(p.default)}\n"
if p.description:
params_yml += f" # {p.description}\n"
# Build inputs section
# Asserts from parameter min/max bounds
asserts_yml = ""
assert_lines = []
for p in generated.parameters:
if p.min_value is not None:
assert_lines.append(f"- ${{ {p.name} >= {p.min_value} }}")
if p.max_value is not None:
assert_lines.append(f"- ${{ {p.name} <= {p.max_value} }}")
if assert_lines:
asserts_yml = "asserts:\n" + "\n".join(assert_lines) + "\n"
# Inputs
inputs_yml = ""
for i, inp in enumerate(generated.inputs):
inputs_yml += f"""- label: in{i}
domain: stream
dtype: {inp.dtype}
vlen: {inp.vlen}
"""
inputs_yml += f"- label: in{i}\n"
inputs_yml += f" domain: stream\n"
inputs_yml += f" dtype: {inp.dtype}\n"
if inp.vlen > 1:
inputs_yml += f" vlen: {inp.vlen}\n"
# Build outputs section
# Outputs
outputs_yml = ""
for i, out in enumerate(generated.outputs):
outputs_yml += f"""- label: out{i}
domain: stream
dtype: {out.dtype}
vlen: {out.vlen}
"""
outputs_yml += f"- label: out{i}\n"
outputs_yml += f" domain: stream\n"
outputs_yml += f" dtype: {out.dtype}\n"
if out.vlen > 1:
outputs_yml += f" vlen: {out.vlen}\n"
# Documentation
# Documentation from docstring or generation prompt
doc = block_info.get("doc", generated.generation_prompt or "Custom block")
return BLOCK_YML_TEMPLATE.format(
module_name=module_name,
block_name=block_name,
block_id=block_id,
block_label=block_label,
make_args=make_args,
category=cat,
import_line=import_line,
make_line=make_line,
callbacks=callbacks,
parameters=params_yml or "[]",
inputs=inputs_yml or "[]",
outputs=outputs_yml or "[]",
asserts=asserts_yml,
documentation=doc.replace("\n", "\n "),
)

View File

@ -677,6 +677,21 @@ class IQAnalysisResult(BaseModel):
# ──────────────────────────────────────────────
class GrcYamlResult(BaseModel):
"""Result of GRC YAML block definition generation.
Contains the YAML content for a .block.yml file that makes a
block usable in GNU Radio Companion's graphical editor.
"""
success: bool
block_id: str = "" # e.g. "custom_my_gain"
yaml_content: str = ""
filename: str = "" # e.g. "custom_my_gain.block.yml"
error: str | None = None
notes: list[str] = [] # Warnings or suggestions
class OOTExportResult(BaseModel):
"""Result of exporting an embedded block to full OOT module.

View File

@ -8,6 +8,7 @@ from gnuradio_mcp.prompts.sync_block import SYNC_BLOCK_PROMPT
from gnuradio_mcp.prompts.basic_block import BASIC_BLOCK_PROMPT
from gnuradio_mcp.prompts.decoder_chain import DECODER_CHAIN_PROMPT
from gnuradio_mcp.prompts.common_patterns import COMMON_PATTERNS_PROMPT
from gnuradio_mcp.prompts.grc_yaml import GRC_YAML_PROMPT
from gnuradio_mcp.prompts.protocol_templates import (
PROTOCOL_TEMPLATES,
BLUETOOTH_LE_TEMPLATE,
@ -24,6 +25,7 @@ __all__ = [
"BASIC_BLOCK_PROMPT",
"DECODER_CHAIN_PROMPT",
"COMMON_PATTERNS_PROMPT",
"GRC_YAML_PROMPT",
# Protocol templates
"PROTOCOL_TEMPLATES",
"BLUETOOTH_LE_TEMPLATE",

View File

@ -0,0 +1,124 @@
"""GRC YAML block definition guidance for LLM-assisted generation."""
GRC_YAML_PROMPT = """# GRC Block Definition (.block.yml) Guide
GNU Radio Companion (GRC) requires a `.block.yml` file for each block to
appear in the graphical editor. Without this file, blocks are only
available programmatically.
## When to Generate GRC YAML
Generate a .block.yml when:
- The user will use GNU Radio Companion's graphical editor
- The block is being exported to an OOT module
- The block needs a GUI properties dialog
You do NOT need a .block.yml for:
- Blocks used only in Python scripts
- Embedded Python blocks (epy_block) GRC handles these internally
- Temporary/test blocks
## Key Fields
```yaml
id: module_block # Unique ID, format: {module}_{block}
label: Block Name # Display name in GRC palette
category: [Module Name] # GRC palette category
flags: [ python ] # Implementation language
templates:
imports: from module import block # Python import
make: module.block(gain=${gain}) # Constructor with ${param} substitution
callbacks: # Runtime update methods
- set_gain(${gain})
parameters:
- id: gain
label: Gain
dtype: real # real, int, string, bool, enum, raw, complex
default: 1.0
inputs:
- label: in0
domain: stream # stream (samples) or message (PDUs)
dtype: float # float, complex, byte, short, int
outputs:
- label: out0
domain: stream
dtype: float
asserts: # Parameter validation
- ${ gain >= 0 }
documentation: |-
Block description shown in GRC tooltips.
file_format: 1
```
## GRC dtype vs Python dtype
| Python type | GRC parameter dtype | GRC port dtype |
|-------------|--------------------:|---------------:|
| float | real | float |
| int | int | int |
| str | string | |
| bool | bool | |
| complex | complex | complex |
| bytes | | byte |
## Callbacks (Runtime Updates)
For parameters to update at runtime in GRC, the block must have
setter methods and the YAML must list them as callbacks:
```python
# Python block
def set_gain(self, gain):
self.gain = gain
```
```yaml
# YAML definition
templates:
callbacks:
- set_gain(${gain})
```
## Enum Parameters
```yaml
- id: mode
label: Mode
dtype: enum
default: 'low'
options: ['low', 'medium', 'high']
option_labels: ['Low Power', 'Medium Power', 'High Power']
```
## Message Ports
```yaml
inputs:
- domain: message
id: pdu_in
optional: true
outputs:
- domain: message
id: pdu_out
```
## File Location
Install .block.yml files to `$GR_DATA_DIR/grc/blocks/` (typically
`/usr/share/gnuradio/grc/blocks/`). OOT modules handle this via
CMake use `export_block_to_oot()` for the full workflow.
## Workflow
1. Generate block code: `generate_sync_block(...)`
2. Generate GRC YAML: `generate_grc_yaml(source_code, block_name, ...)`
3. (Optional) Export to OOT: `export_block_to_oot(...)` includes YAML
"""

View File

@ -14,6 +14,7 @@ from gnuradio_mcp.models import (
BlockTestResult,
DecoderPipelineModel,
GeneratedBlockCode,
GrcYamlResult,
IQAnalysisResult,
OOTExportResult,
OOTSkeletonResult,
@ -654,6 +655,65 @@ if __name__ == "__main__":
return self._protocol_analyzer.get_missing_oot_modules(pipeline)
# ──────────────────────────────────────────
# GRC YAML Generation
# ──────────────────────────────────────────
def generate_grc_yaml(
self,
source_code: str,
block_name: str,
module_name: str = "custom",
inputs: list[dict[str, Any]] | None = None,
outputs: list[dict[str, Any]] | None = None,
parameters: list[dict[str, Any]] | None = None,
category: str | None = None,
) -> GrcYamlResult:
"""Generate a GRC YAML block definition for GNU Radio Companion.
Produces a .block.yml file that makes a Python block appear in
GRC's block palette with configurable parameters, typed ports,
and runtime callbacks. Without this file, blocks can only be
used programmatically not in GRC's graphical editor.
Args:
source_code: Python source code of the block
block_name: Block name (e.g., "my_gain")
module_name: Module namespace (default "custom")
inputs: Input port specs [{"dtype": "float", "vlen": 1}]
If not provided, extracted from code (defaults to 1x float)
outputs: Output port specs (same format as inputs)
parameters: Block params [{"name": "gain", "dtype": "float", "default": 1.0}]
category: GRC palette category (default: module_name)
Returns:
GrcYamlResult with YAML content. Save as
grc/{module}_{block}.block.yml in your OOT module.
Note:
For blocks to appear in GRC, the .block.yml must be
installed to $GR_DATA_DIR/grc/blocks/ (typically
/usr/share/gnuradio/grc/blocks/). OOT modules do this
via CMake see export_block_to_oot() for the full workflow.
"""
input_items = [SignatureItem(**inp) for inp in (inputs or [{"dtype": "float", "vlen": 1}])]
output_items = [SignatureItem(**out) for out in (outputs or [{"dtype": "float", "vlen": 1}])]
param_items = [BlockParameter(**p) for p in (parameters or [])]
generated = GeneratedBlockCode(
source_code=source_code,
block_name=block_name,
block_class="sync_block",
inputs=input_items,
outputs=output_items,
parameters=param_items,
is_valid=True,
)
return self._oot_exporter.generate_grc_yaml(
generated, module_name, category
)
# ──────────────────────────────────────────
# OOT Module Export
# ──────────────────────────────────────────

View File

@ -104,6 +104,7 @@ class McpBlockDevProvider:
- validate_block_code: Static code analysis
- test_block_in_docker: Isolated testing (if Docker available)
- inject_block: Add generated block to flowgraph
- generate_grc_yaml: Create .block.yml for GNU Radio Companion
- parse_protocol_spec: Extract protocol params from description
- generate_decoder_chain: Generate block pipeline from protocol
- analyze_iq_file: Detect signals and modulation in IQ captures
@ -117,6 +118,7 @@ class McpBlockDevProvider:
- Create protocol-specific decoders
- Build and test new DSP algorithms
- Analyze captured signals and auto-generate decoders
- Generate GRC block definitions for graphical editor use
- Export blocks to distributable OOT modules
"""
if self._block_dev_enabled:
@ -205,6 +207,9 @@ class McpBlockDevProvider:
# Signal analysis tools (Phase 4)
self._add_tool("analyze_iq_file", p.analyze_iq_file)
# GRC YAML generation
self._add_tool("generate_grc_yaml", p.generate_grc_yaml)
# OOT export tools (Phase 5)
self._add_tool("generate_oot_skeleton", p.generate_oot_skeleton)
self._add_tool("export_block_to_oot", p.export_block_to_oot)
@ -474,6 +479,7 @@ class McpBlockDevProvider:
BASIC_BLOCK_PROMPT,
COMMON_PATTERNS_PROMPT,
DECODER_CHAIN_PROMPT,
GRC_YAML_PROMPT,
SYNC_BLOCK_PROMPT,
)
@ -513,7 +519,16 @@ class McpBlockDevProvider:
def get_common_patterns_prompt() -> str:
return COMMON_PATTERNS_PROMPT
logger.info("Registered 4 prompt template resources")
@self._mcp.resource(
"prompts://block-generation/grc-yaml",
name="grc_yaml_prompt",
description="Guide for generating GRC YAML block definitions (.block.yml)",
mime_type="text/markdown",
)
def get_grc_yaml_prompt() -> str:
return GRC_YAML_PROMPT
logger.info("Registered 5 prompt template resources")
# ──────────────────────────────────────────
# Factory

View File

@ -6,7 +6,7 @@ from pathlib import Path
import pytest
from gnuradio_mcp.middlewares.oot_exporter import OOTExporterMiddleware
from gnuradio_mcp.models import GeneratedBlockCode, SignatureItem
from gnuradio_mcp.models import BlockParameter, GeneratedBlockCode, SignatureItem
class TestOOTExporterMiddleware:
@ -317,3 +317,122 @@ class TestOOTExporterYAMLGeneration:
assert "label:" in yaml_content
assert "category:" in yaml_content
assert "templates:" in yaml_content
class TestGenerateGrcYaml:
"""Tests for the standalone generate_grc_yaml method."""
@pytest.fixture
def exporter(self):
return OOTExporterMiddleware()
@pytest.fixture
def gain_block(self):
return GeneratedBlockCode(
source_code='''
import numpy
from gnuradio import gr
class blk(gr.sync_block):
"""Configurable gain block."""
def __init__(self, gain=1.0):
gr.sync_block.__init__(
self, name="configurable_gain",
in_sig=[numpy.float32], out_sig=[numpy.float32],
)
self.gain = gain
def set_gain(self, gain):
self.gain = gain
def work(self, input_items, output_items):
output_items[0][:] = input_items[0] * self.gain
return len(output_items[0])
''',
block_name="configurable_gain",
block_class="sync_block",
inputs=[SignatureItem(dtype="float", vlen=1)],
outputs=[SignatureItem(dtype="float", vlen=1)],
parameters=[
BlockParameter(name="gain", dtype="float", default=1.0,
description="Gain factor", min_value=0.0),
],
is_valid=True,
)
def test_returns_success(self, exporter, gain_block):
"""generate_grc_yaml returns a successful result."""
result = exporter.generate_grc_yaml(gain_block)
assert result.success is True
assert result.yaml_content
assert result.filename == "custom_configurable_gain.block.yml"
assert result.block_id == "custom_configurable_gain"
def test_yaml_has_flags(self, exporter, gain_block):
"""Generated YAML includes flags: [ python ]."""
result = exporter.generate_grc_yaml(gain_block)
assert "flags: [ python ]" in result.yaml_content
def test_yaml_has_callbacks(self, exporter, gain_block):
"""Setter methods are detected as GRC callbacks."""
result = exporter.generate_grc_yaml(gain_block)
assert "set_gain(${gain})" in result.yaml_content
assert "callbacks:" in result.yaml_content
def test_yaml_has_asserts_from_min_value(self, exporter, gain_block):
"""Parameter min_value generates GRC asserts."""
result = exporter.generate_grc_yaml(gain_block)
assert "gain >= 0.0" in result.yaml_content
def test_yaml_has_parameter_dtype_mapping(self, exporter, gain_block):
"""Python 'float' maps to GRC 'real' dtype."""
result = exporter.generate_grc_yaml(gain_block)
assert "dtype: real" in result.yaml_content
def test_yaml_has_documentation(self, exporter, gain_block):
"""Docstring is included as documentation."""
result = exporter.generate_grc_yaml(gain_block)
assert "Configurable gain block" in result.yaml_content
def test_custom_module_name(self, exporter, gain_block):
"""Module name changes block ID and import."""
result = exporter.generate_grc_yaml(gain_block, module_name="dsp")
assert result.block_id == "dsp_configurable_gain"
assert "from dsp import configurable_gain" in result.yaml_content
def test_custom_category(self, exporter, gain_block):
"""Category can be overridden."""
result = exporter.generate_grc_yaml(gain_block, category="Signal Processing")
assert "category: [Signal Processing]" in result.yaml_content
def test_notes_for_no_setters(self, exporter):
"""Notes warn about missing setter methods."""
block = GeneratedBlockCode(
source_code='''
import numpy
from gnuradio import gr
class blk(gr.sync_block):
def __init__(self, gain=1.0):
gr.sync_block.__init__(self, name="no_setters",
in_sig=[numpy.float32], out_sig=[numpy.float32])
self.gain = gain
def work(self, input_items, output_items):
output_items[0][:] = input_items[0] * self.gain
return len(output_items[0])
''',
block_name="no_setters",
block_class="sync_block",
inputs=[SignatureItem(dtype="float", vlen=1)],
outputs=[SignatureItem(dtype="float", vlen=1)],
parameters=[BlockParameter(name="gain", dtype="float", default=1.0)],
is_valid=True,
)
result = exporter.generate_grc_yaml(block)
assert any("set_*()" in note for note in result.notes)
def test_file_format_1(self, exporter, gain_block):
"""YAML includes file_format: 1."""
result = exporter.generate_grc_yaml(gain_block)
assert "file_format: 1" in result.yaml_content