Coral Turns REST APIs Into SQL Tables You Can Join

May 19, 2026

|repo-review

by Florian Narr

Coral Turns REST APIs Into SQL Tables You Can Join

Coral is a local-first SQL runtime, written in Rust, that turns declarative YAML "source specs" into queryable SQL schemas. Install the GitHub source and github.issues, github.pulls become tables you can SELECT from. Run coral mcp-stdio and the same catalog shows up to an agent over MCP as a read-only database instead of a pile of bespoke tools.

Why I starred it

The pitch is straightforward: instead of wiring an agent to twenty separate MCP servers for Datadog, Sentry, Linear, Slack, and GitHub — each with its own pagination quirks, auth flow, and JSON shape — you give it one SQL interface and let the agent write joins. Coral's own benchmark (82 real-world tasks against Claude Opus 4.6, compared to direct provider MCPs for Datadog, Sentry, Linear, Slack, and GitHub) claims 20% higher accuracy, 2x lower token cost, and 42% lower latency, with the gap widening on multi-hop tasks. I can't verify those numbers myself. What convinced me to actually clone the thing was the engineering underneath the claim.

How it works

The SQL layer is Apache DataFusion — crates/coral-engine/Cargo.toml pulls in datafusion = "54" plus datafusion-table-providers for Postgres, MySQL, and SQLite. Every HTTP source becomes a TableProvider: HttpSourceTableProvider in crates/coral-engine/src/backends/http/provider.rs wraps a manifest-declared table and turns a scan into a JsonExec that fetches rows from the upstream API on demand.

The part worth actually reading is crates/coral-engine/src/runtime/dependent_join/. It's a custom DataFusion OptimizerRule (DependentJoinOptimizerRule, optimizer.rs:25) that intercepts inner equi-joins between two HTTP-backed tables — the pattern behind the README's example of joining linear.attachments to github.pulls on URL. Instead of materializing both sides and hash-joining them client-side, it peels the join apart, treats one side as a "resolver" that supplies key values, and rewrites the other side's scan into a set of parameterized API calls, one per distinct key tuple rather than one per row. DependentJoinRuntimeState in state.rs dedupes tuples through bindings_by_tuple and seen_tuples (state.rs:31-32) before a single request goes out — a resolver returning 10,000 rows with 40 distinct customer_ids issues roughly 40 upstream calls, not 10,000.

Guardrails live in DependentJoinConfig::default() (crates/coral-engine/src/contracts/query.rs:887):

Self {
    enabled: true,
    max_bindings: 500,
    max_resolver_rows: 10_000,
    max_rows_per_binding: 1_000,
    max_resolver_rows_per_binding: 1_000,
    max_concurrency: 8,
    per_source: BTreeMap::new(),
}

If a join doesn't fit the pattern, DependentJoinFallbackReason (optimizer.rs:30) records exactly why — not_inner_join, not_inner_equi_join, mixed_or_missing_lookup_key_filter, over_constrained_filter — and the query drops back to normal execution instead of erroring out. That's the kind of failure path that's easy to skip and expensive to get wrong once you're fanning out real API calls behind a query planner.

Source specs are declarative YAML. sources/core/github/manifest.yaml defines auth (HeaderAuth with a Bearer {{input.GITHUB_TOKEN}} template), pagination mode, and per-column JSON path extraction for each table — pagination alone gets 969 lines in backends/http/pagination.rs to cover page, cursor, offset, and link-header strategies. The GitHub manifest itself is 197,353 lines covering 364 tables; its README notes it's OpenAPI-generated from GitHub's REST spec via the openapi-hydrate and generate-schemas commands in xtask/src/main.rs, not hand-written.

Using it

curl -fsSL https://withcoral.com/install.sh | sh
coral source add --interactive github
coral sql "
  SELECT number, title, state
  FROM github.issues
  WHERE owner = 'withcoral' AND repo = 'coral' AND state = 'open'
  ORDER BY created_at DESC LIMIT 10
"

The cross-source join that actually exercises the dependent-join path:

SELECT a.issue_identifier, a.url, p.state
FROM linear.attachments a
JOIN github.pulls p ON p.html_url = a.url
WHERE p.owner = 'withcoral' AND p.repo = 'coral'

Wired to Claude Code with claude mcp add --scope user coral -- coral mcp-stdio, the agent gets coral.tables, coral.table_functions, and coral.columns as catalog-discovery tables instead of a separate tool schema per provider.

Rough edges

  • unsafe_code = "forbid" and clippy::pedantic at warn-level in the workspace Cargo.toml — a serious lint posture for software that's still moving fast: four minor releases in three weeks, v0.9.0 on 2026-07-31 up through v0.13.0 on 2026-08-17.
  • Read-only by design, and the README says so plainly. No writes means it doesn't replace a full MCP integration when the agent actually needs to file a Linear issue or ack a PagerDuty alert.
  • A comment in the root Cargo.toml flags that the Postgres/MySQL adapters hard-enable native-tls (OpenSSL on Linux) with no rustls option yet — a known tradeoff the maintainers surface themselves rather than bury.
  • I couldn't find a response-caching layer, only HTTP client reuse (backends/common.rs). Every query re-fetches from source, so repeated queries in a session pay repeated API cost — worth knowing if you're rate-limited.
  • The bundled GitHub source is generated and enormous; if the OpenAPI import misses a field or GitHub reshapes a response, you're waiting on a regen rather than patching one table by hand. Writing a custom source spec for anything not bundled is documented, but it's still YAML you have to get right.

Bottom line

If you're wiring an agent to more than two or three APIs and tired of maintaining one MCP tool per endpoint, Coral's dependent-join optimizer is exactly the kind of unglamorous engineering that makes the SQL-over-APIs idea actually work instead of just sounding good. If you only need one API, plain MCP tool calls are simpler and you don't need a query planner for it.

withcoral/coral on GitHub
withcoral/coral