Testing MCP servers in CI

July 1, 2026 ยท View on GitHub

Ocarina replays a rondo against an MCP server with no model in the loop, so a rondo is a deterministic test: it produces the same result today and next month, and it spends no tokens. This guide covers the CI workflow, from a first assertion to a published test report.

For the field and flag reference, see the README and architecture. This page is the full workflow.

Prerequisites

  • An MCP server you can start from a command (stdio) or reach at a URL (HTTP).
  • Ocarina installed. In CI: go install github.com/msradam/ocarina@v0.5.0. Pin the version so the toolchain is reproducible.

1. Get a rondo

Write one by hand, or record a real session. Recording captures what a model did, including arguments you would not have written yourself:

# point your MCP host's config at this instead of the server directly
ocarina record session.yaml uvx mcp-server-fetch

record proxies stdio and writes every tools/call into session.yaml, args exactly as sent, with each tool's output stored in a result: block.

Commit the rondo. It is now both a spec and a test.

2. Assert on replay with snapshots

--snapshot compares each step's live output against its recorded result: block and fails on any drift. Capture a baseline once, then assert on every run:

ocarina play session.yaml --update      # capture or re-baseline the result: blocks
ocarina play session.yaml --snapshot    # assert; exits non-zero on drift

In CI, run --snapshot only, never --update. A drift then fails the build instead of being silently rewritten. A step with no baseline fails rather than passing green, so a forgotten --update cannot hide a gap.

Snapshots suit deterministic output. For a value that changes every call (a timestamp, a fresh id), assert the stable part with expect: instead:

- name: fetch the homepage
  tool: fetch
  args: { url: "https://example.com" }
  expect:
    contains: "Example Domain"

3. Gate latency

expect.max_duration fails a step whose tool call runs longer than the budget. It times the successful attempt only, excluding retry backoff, so the budget applies to a single call, not the retry loop's total:

- name: search stays snappy
  tool: search
  args: { q: "widgets" }
  expect:
    contains: "results"
    max_duration: 500ms

One ocarina play now catches both "the tool broke" and "the tool got slow." For throughput and percentiles under concurrency, use ocarina load with a --threshold gate.

4. Run across many inputs

--data plays the whole rondo once per row of a CSV or JSON file, with each column injected as a {{key}}:

# zone-check.yaml
- name: time is reported for the zone
  tool: get_current_time
  args: { timezone: "{{tz}}" }
  expect:
    contains: "{{tz}}"
ocarina play zone-check.yaml --data timezones.csv

Each row is a separate case, and its values ride along in the report so a failure maps back to the input that caused it.

5. Catch schema drift and bad arguments before running

validate checks a rondo against the live tool schemas without calling anything. --strict turns an out-of-schema argument (the kind a real model invents) into a build failure:

ocarina validate session.yaml --strict

For schema drift over time, ocarina diff compares a rondo against current schemas, and ocarina lock --check fails when a locked schema changes.

6. Publish a test report

--output junit emits JUnit XML, the format CI test dashboards ingest (GitLab reports, the Jenkins JUnit plugin, the common GitHub Actions reporters). One rondo is a test suite; each step is a test case:

ocarina play session.yaml --snapshot --output junit > results.xml

The XML goes to stdout on its own; human progress and failures go to stderr, so the redirect stays clean.

Full GitHub Actions workflow

name: MCP tests
on: [push, pull_request]

jobs:
  mcp:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: stable
      - run: go install github.com/msradam/ocarina@v0.5.0

      # pre-flight: fail on out-of-schema args before any tool runs
      - run: ocarina validate tests/session.yaml --strict

      # replay with snapshot assertions, emit a JUnit report
      - run: ocarina play tests/session.yaml --snapshot --output junit > results.xml

      # publish the report even when the play step failed
      - if: always()
        uses: dorny/test-reporter@v1
        with:
          name: MCP replay
          path: results.xml
          reporter: java-junit

For a smoke test with no report, the composite action is enough:

- uses: msradam/ocarina@v0.5.0
  with:
    rondo: tests/mcp-smoke.yaml

See action.yml.

How failures gate the build

play exits 0 when every assertion passes and non-zero otherwise, so it gates a pipeline without extra wiring. A step fails on a failed expect:, a snapshot drift, a missing baseline, an exceeded max_duration, a tool error (isError: true) unless the step opts out, or an unresolved {{key}}. A rondo that resolves to zero steps is an error too, so a mistyped top-level key fails loudly instead of passing as a no-op.