duckdb-mcp-ocarina

June 29, 2026 ยท View on GitHub

A working environment that tests a DuckDB database through its MCP server, with no LLM. Clone it, run one command, and watch deterministic data checks run against a real server.

The database is driven entirely from YAML scripts called rondos, using Ocarina. Each rondo runs SQL through the DuckDB MCP server, captures values out of the JSON results, and asserts on them. The data lives in this repo as CSV files, so a fresh clone runs the same way on any machine, with no signup and no API key.

Prerequisites

  • Ocarina: go install github.com/msradam/ocarina@latest
  • uv for uvx: curl -LsSf https://astral.sh/uv/install.sh | sh

uvx fetches the DuckDB MCP server on first run. There is nothing else to install.

Quick start

git clone https://github.com/msradam/duckdb-mcp-ocarina
cd duckdb-mcp-ocarina
make test

make test runs every rondo in order. Each exits non-zero if an assertion fails, so the same commands work as a CI gate. Run them one at a time with make smoke, make audit, make migrate, make regression, or directly:

ocarina play rondos/audit.yaml

What each rondo does

RondoWhat it proves
rondos/smoke.yamlThe server starts, answers a query, and writes survive across steps on one connection. The 30-second "is it alive" gate.
rondos/audit.yamlData integrity over the shipped CSVs: primary keys are unique, no required field is null, and every order points at a real customer.
rondos/migrate.yamlA schema migration preserves every row and fully populates the new derived column.
rondos/regression.yamlBusiness metrics (order count, distinct customers, paid revenue, refunds) match pinned golden values. Drift fails the build.
rondos/transactional.yamlblock / rescue / always: stage rows, validate them, and always drop the staging table, so the teardown runs even when a step fails.
rondos/report.yamlServed as a composite MCP tool. params: become the tool input, several queries run, and one summary line comes back.

The data is in data/customers.csv and data/orders.csv. DuckDB reads them in place with read_csv_auto, so the checks run against the files exactly as committed.

All four rondos share one server, declared once in .mcp.json and referenced by name as server: duckdb. Ocarina reads .mcp.json from the working directory, so the rondos stay short and the connection details live in one place.

Serve a composite tool

rondos/report.yaml is not played, it is served. ocarina serve exposes the whole rondo as a single MCP tool named sales_summary. The rondo's params: become the tool input schema, the body runs three queries (load the orders, load the customers, aggregate), and return: names the line handed back. It is a stored procedure for the CSVs in this repo: an agent calls one tool instead of issuing the queries itself.

ocarina serve rondos/report.yaml
# call it, here with ocarina itself as the client:
ocarina hum ocarina serve rondos/report.yaml -- sales_summary status=paid

Transactions and teardown

rondos/transactional.yaml uses block:, rescue:, and always:, the same error handling as an Ansible playbook. The block stages rows and validates them, rescue: runs only on failure, and always: drops the staging table whether the block passed or failed. A clean run still exits 0, and the table never leaks.

ocarina play rondos/transactional.yaml

Make it fail

The tests catch real problems, not just pass on clean data. data/orders_broken.csv has a duplicate primary key and an order whose customer does not exist. Point the audit at it and watch it fail:

sed 's/orders.csv/orders_broken.csv/' rondos/audit.yaml > /tmp/audit-broken.yaml
ocarina play /tmp/audit-broken.yaml
# the "order id is unique" and "every order points at a real customer"
# checks fail, and ocarina exits non-zero

In CI

.github/workflows/ci.yml installs Ocarina and uv, then plays every rondo on each push. A failing assertion fails the job. No database service, no credentials, no fixtures to host.

How it works

There is no model in the loop. A rondo is an ordered list of steps; each calls the execute_query tool with SQL, grabs a value out of the JSON result with a gjson path, and checks it with expect. Run twice, get the same result. See the Ocarina docs for the full rondo format.

License

MIT.