Recipes

LiteX용 엔드투엔드 MCP 시뮬레이션 테스트 및 전용 CI job

README 링크에서 발견 · today

Software & DevOps

litex_sim과 litex_server를 MCP를 통해 구동하는 MCP SDK stdio 테스트를 추가했습니다

What they did

작성자는 litex_mcp를 위한 엔드투엔드 MCP 테스트와 전용 CI job을 추가했다. 테스트 파일 test/tools/test_litex_mcp_sim.py는 litex_mcp 서버를 --allow-write와 함께 서브프로세스로 시작하고, MCP SDK stdio 클라이언트를 통해 연결하며, 환경 변수를 명시적으로 전달해 PYTHONPATH가 서버에 도달하도록 한다. 그 이후의 모든 과정은 MCP 도구만을 통해 이루어진다: VexRiscv CPU와 --uart-name=crossover+uartbone --uart-tcp를 사용해 빈 포트에서 litex_sim_start를 실행하고, 시뮬레이션이 리스닝할 때까지 대기하며(프로세스가 종료되면 로그 tail과 함께 빠르게 실패), 다른 빈 포트에서 litex_server_start를 실행하고, 서버가 연결을 받아들일 때까지 litex_connect를 재시도하고, ctrl_scratch를 읽고 쓰고, crossover 콘솔을 통해 BIOS help 명령을 실행하며, 서버가 지원하는 경우(#2745) 시뮬레이션 job에 대한 litex_build_info 확인까지 수행한다. 이 테스트는 LITEX_MCP_SIM_TEST=1이고 MCP SDK와 Verilator가 존재하지 않으면 건너뛰어지므로, 일반 pytest shard에서는 skipped로만 수집된다. .github/workflows/ci.yml에는 Python 3.11 기반의 mcp job을 추가했는데, mcp를 pip 설치하고, LiteX를 --config=standard로 빌드하고, gcc-riscv64-unknown-elf를 설치하고, ccache를 사용하며, build job과 동일한 캐시 키로 Verilator를 가져오고, 캐시된 Verilator 설치가 빌드 시점 인터프리터를 호출하기 때문에 Python 3.9도 함께 사용한다. 이 job은 test/tools/test_litex_mcp.py와 test/build/test_report.py를 blocking으로 실행하고, 시뮬레이션 테스트는 continue-on-error로 실행한다. build shard, 해당 .test_durations 생성, docs job은 변경하지 않았다. 동기: pytest shard는 MCP SDK(Python >= 3.10 필요)가 없는 Python 3.9에서 실행되므로 litex_mcp의 SDK 측면(서버 클래스, 도구 등록, 어노테이션, stdio 프로토콜)이 CI에서 한 번도 검증되지 않았다. MCP SDK 1.x와 2.x가 서로 다르기 때문에(FastMCP vs MCPServer, snake_case 결과 필드), 테스트는 model_dump(by_alias=True)를 통해 결과를 읽어 두 버전 모두에서 동작하도록 한다.

How it went

로컬에서 시뮬레이션 테스트는 MCP SDK 2.3.0에서 약 44.8초, 1.30.0에서 43.3초(Verilator 5.025)로 통과했으며, litex_build_info 검사를 실행하는 #2745의 litex_mcp에서도 통과했습니다. LITEX_MCP_SIM_TEST가 없으면 건너뛰며, ci.yml은 build, mcp, docs job으로 파싱됩니다. GitHub Actions에서 mcp job은 Verilator 캐시 히트 시 약 3분이 걸리고 SDK 단위 테스트는 통과합니다(21개 통과, mcp 2.3.0). 시뮬레이션 테스트는 4회 중 2회 통과했으며(48초), 나머지 2회에서는 시뮬레이션이 빌드되고 리스닝했으며 litex_server가 연결되었지만 첫 번째부터 모든 UARTBone 읽기가 타임아웃되어, 현재 해당 스텝은 continue-on-error로 설정되어 있습니다.

Worth knowing

시뮬레이션 테스트가 GitHub Actions에서 불안정합니다: 4회 실행 중 2회가 2분 동안 UART 읽기 타임아웃(0/4 바이트 수신)과 30초 연결 타임아웃으로 실패했으며, 로컬에서는 재현되지 않습니다(5/5 통과, taskset으로 2코어에 고정한 경우도 포함). MCP 문제라기보다는 litex_sim/serial2tcp 시작 문제로 보이므로, 해당 단계는 continue-on-error로 설정하고 ci.yml에 주석을 달았으며, 테스트는 실패 시 작업 로그를 출력합니다. 차단 단계가 되기 전에 조사가 필요합니다. 이 테스트는 LITEX_MCP_SIM_TEST=1이고 MCP SDK와 Verilator가 설치된 경우에만 실행되므로, 일반 샤드는 그냥 건너뜁니다. 브랜치에는 CI 반복 과정에서 나온 후속 커밋들(ccache, Python 3.9, 진단, 정보성 단계)이 포함되어 있으며, 병합 시 squash될 수 있습니다. AI 에이전트(Claude Code)가 작성했으며 Florent Kermarrec이 감독했습니다.

Wiring

MCP SDK stdio 클라이언트는 명시적 env와 함께 `litex_mcp --allow-write`를 서브프로세스로 스폰하여 PYTHONPATH가 서버에 전달되도록 한다. 도구 호출은 순서대로 진행된다: litex_sim_start (VexRiscv, --uart-name=crossover+uartbone --uart-tcp를 빈 포트에), 시뮬레이션이 리스닝할 때까지 폴링 (종료 시 로그 tail과 함께 실패), 두 번째 빈 포트에서 litex_server_start, 서버가 수락할 때까지 litex_connect 재시도, 그다음 ctrl_scratch 읽기/쓰기, crossover 콘솔을 통한 BIOS `help` 명령, 그리고 가능한 경우 litex_build_info. 결과는 MCP SDK 1.x/2.x 호환성을 위해 model_dump(by_alias=True)를 통해 읽는다. 테스트는 LITEX_MCP_SIM_TEST=1과 MCP SDK 및 Verilator를 조건으로 게이트된다. ci.yml에서 새 `mcp` job은 Python 3.11에서 `pip install mcp`로 실행되며, LiteX는 --config=standard로 빌드되고, gcc-riscv64-unknown-elf, ccache, `build` job과 동일한 캐시 키에서 복원된 Verilator, 그리고 캐시된 Verilator 설치의 빌드 시점 인터프리터를 위해 Python 3.9가 존재한다. 이 job은 test/tools/test_litex_mcp.py, test/build/test_report.py (blocking), test/tools/test_litex_mcp_sim.py (continue-on-error)를 실행한다. 빌드 샤드, .test_durations 생성, docs job은 변경되지 않는다.

Try it yourself

```python
# test/tools/test_litex_mcp_sim.py
import json
import os
import socket
import subprocess
import sys
import time

import pytest

pytestmark = pytest.mark.skipif(
    os.environ.get("LITEX_MCP_SIM_TEST") != "1",
    reason="Set LITEX_MCP_SIM_TEST=1 to run the litex_mcp simulation test",
)


def _require(name):
    try:
        __import__(name)
    except ImportError:
        pytest.skip(f"{name} not available")


def _free_port():
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


def _wait_for_port(port, timeout=120.0):
    deadline = time.time() + timeout
    while time.time() < deadline:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.settimeout(1.0)
            try:
                s.connect(("127.0.0.1", port))
                return True
            except OSError:
                time.sleep(0.5)
    return False


def _result_payload(result):
    if hasattr(result, "model_dump"):
        return result.model_dump(by_alias=True)
    return result


def _tool_text(result):
    payload = _result_payload(result)
    content = payload.get("content") or []
    parts = []
    for item in content:
        if isinstance(item, dict) and item.get("type") == "text":
            parts.append(item.get("text", ""))
    return "\n".join(parts)


def test_litex_mcp_simulation():
    _require("mcp")
    if not _has_verilator():
        pytest.skip("Verilator not available")

    from mcp import ClientSession, StdioServerParameters
    from mcp.client.stdio import stdio_client

    uart_port = _free_port()
    server_port = _free_port()

    env = dict(os.environ)
    env["PYTHONPATH"] = os.pathsep.join(
        p for p in [os.getcwd(), env.get("PYTHONPATH", "")] if p
    )

    params = StdioServerParameters(
        command="litex_mcp",
        args=["--allow-write"],
        env=env,
    )

    import anyio

    async def run():
        async with stdio_client(params) as (read, write):
            async with ClientSession(read, write) as session:
                await session.initialize()

                start = await session.call_tool(
                    "litex_sim_start",
                    {
                        "cpu": "vexriscv",
                        "extra_args": [
                            "--uart-name=crossover+uartbone",
                            "--uart-tcp",
                            str(uart_port),
                        ],
                    },
                )
                start_payload = _result_payload(start)
                if start_payload.get("isError"):
                    pytest.fail(f"litex_sim_start failed: {_tool_text(start)}")

                if not _wait_for_port(uart_port, timeout=180.0):
                    log_tail = _tool_text(
                        await session.call_tool("litex_sim_log", {"tail": 50})
                    )
                    pytest.fail(
                        "Simulation did not start listening on UART port.\n"
                        f"Log tail:\n{log_tail}"
                    )

                srv = await session.call_tool(
                    "litex_server_start",
                    {"port": server_port},
                )
                if _result_payload(srv).get("isError"):
                    pytest.fail(f"litex_server_start failed: {_tool_text(srv)}")

                connected = False
                last_err = ""
                for _ in range(30):
                    conn = await session.call_tool(
                        "litex_connect",
                        {"host": "127.0.0.1", "port": server_port},
                    )
                    if not _result_payload(conn).get("isError"):
                        connected = True
                        break
                    last_err = _tool_text(conn)
                    time.sleep(1.0)
                if not connected:
                    pytest.fail(f"litex_connect never accepted: {last_err}")

                write = await session.call_tool(
                    "litex_write",
                    {"name": "ctrl_scratch", "value": 0xDEADBEEF},
                )
                if _result_payload(write).get("isError"):
                    pytest.fail(f"litex_write failed: {_tool_text(write)}")

                read = await session.call_tool(
                    "litex_read",
                    {"name": "ctrl_scratch"},
                )
                if _result_payload(read).get("isError"):
                    pytest.fail(f"litex_read failed: {_tool_text(read)}")
                assert "deadbeef" in _tool_text(read).lower()

                help_out = await session.call_tool(
                    "litex_console_command",
                    {"command": "help"},
                )
                if _result_payload(help_out).get("isError"):
                    pytest.fail(f"console help failed: {_tool_text(help_out)}")
                assert _tool_text(help_out).strip()

                tools = await session.list_tools()
                tool_names = {t.name for t in tools.tools}
                if "litex_build_info" in tool_names:
                    info = await session.call_tool("litex_build_info", {})
                    assert not _result_payload(info).get("isError")

    anyio.run(run)


def _has_verilator():
    try:
        subprocess.run(
            ["verilator", "--version"],
            check=True,
            capture_output=True,
        )
        return True
    except (OSError, subprocess.CalledProcessError):
        return False
```

```yaml
# .github/workflows/ci.yml (add the `mcp` job; leave build shards, .test_durations generation and docs job unchanged)
  mcp:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python 3.11
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Set up Python 3.9 for cached Verilator install
        uses: actions/setup-python@v5
        with:
          python-version: "3.9"

      - name: Install MCP SDK
        run: pip install mcp

      - name: Install gcc-riscv64-unknown-elf
        run: |
          sudo apt-get update
          sudo apt-get install -y gcc-riscv64-unknown-elf

      - name: Set up ccache
        uses: hendrikmuhs/ccache-action@v1

      - name: Restore Verilator cache
        uses: actions/cache@v4
        with:
          path: ~/.cache/verilator
          key: verilator-${{ runner.os }}-${{ hashFiles('.github/workflows/ci.yml') }}

      - name: Install Verilator
        run: |
          if [ ! -x ~/.cache/verilator/bin/verilator ]; then
            sudo apt-get install -y verilator
          fi

      - name: Build LiteX
        run: python -m litex_boards.targets --config=standard

      - name: Run MCP tool tests (blocking)
        run: python -m pytest test/tools/test_litex_mcp.py -v

      - name: Run build report tests (blocking)
        run: python -m pytest test/build/test_report.py -v

      # The simulation test is non-blocking: the UART crossover console
      # occasionally times out waiting for the BIOS prompt under CI load,
      # which is a known flakiness unrelated to the MCP tooling itself.
      - name: Run MCP simulation test (non-blocking)
        continue-on-error: true
        env:
          LITEX_MCP_SIM_TEST: "1"
        run: python -m pytest test/tools/test_litex_mcp_sim.py -v
```

Source

English

Tools in this stack