Programmatic Surface

A BI platform with a real API

Run a saved question and get JSON, CSV, NDJSON or Parquet. Call a stored procedure with named parameters and SETOF output controls. Write rows back with PostgREST semantics. Point an LLM at eight read-only MCP tools using the API token you already have — no new credential type, so a client cannot widen its own scope.

Read

Data API v1

  • Run a saved question — GET or POST
  • Render a report
  • Run one-off ad-hoc SQL — needs the data:read scope
  • Describe a question's parameters
  • Stream a multi-report archive
  • List questions, reports and report packages
  • Output as json, csv, ndjson or parquet
Call & write

RPC and table writes

  • Stored procedures and functions by name
  • Named parameter maps with variadic expansion
  • SETOF output: select, order, limit/offset, ten filter operators
  • Safe-identifier validation, every value bound
  • Table writes accepting JSON or text/csv
  • PostgREST-style Prefer: handling
  • Both require FULL data access
Model access

MCP server — read-only

  • JSON-RPC at /mcp, SSE stream, session termination
  • Protocol version 2024-11-05, tools only
  • Eight read tools, no write tools published
  • Authenticated with existing qrly_* API tokens
  • A client cannot widen its own scope
  • Gated by mcp_enabled, capped by max sessions
  • Per-session tracking and a force-close action
Capability
Shipped Qrly API Data API v1 · RPC · writes · MCP
Access required And the constraint that goes with it
Run a saved question
GET or POST
Token scope plus the creator's collection permissions
Render a report
Included
Same rule — scope in addition to collection permissions
Ad-hoc SQL
One-off queries
Requires the data:read scope explicitly
Describe a question's parameters
Included
So a client can build its own form without guessing
Multi-report archive
Streamed
Report packages are bundles, not one-off exports
Output formats
json · csv · ndjson · parquet
Parquet lands straight in a columnar pipeline
Stored procedures and functions
RPC endpoint
Requires FULL data access on the connection
SETOF output shaping
select · order · limit · filters
Ten operators: eq, neq, gt, gte, lt, lte, like, ilike, is, in
Table writes
JSON or text/csv
Requires FULL data access; ?columns= restricts the set
Upsert / conflict handling
PostgREST-style
merge-duplicates + on_conflict, or ignore-duplicates
Dry-run a write
Prefer: tx=rollback
Plus max-affected=N as a blast-radius guard
MCP write tools
None published, by design
Write paths exist, but not from MCP
API tokens
Hashed, shown once
qrly_live_<32 hex>, SHA-256 at rest, scoped
Rate limiting
Per token
Token bucket, plus per-IP lockout after repeated failures
Health and metrics
/live · /ready · /metrics
Liveness and readiness unauthenticated; metrics in Prometheus text
Legend Shipped Deliberately absent

Run a saved question and get Parquet

The format is a query parameter. Ask for parquet and you get a columnar file a data engineer can load directly, without a CSV round trip and without the type damage a CSV round trip causes.

curl -sS "https://bi.example.com/api/v1/questions/318/run?format=parquet&region=EMEA" \
  -H "Authorization: Bearer qrly_live_0f3c…" \
  -o q318.parquet

# same question, streamed as newline-delimited JSON
curl -sS "https://bi.example.com/api/v1/questions/318/run?format=ndjson&region=EMEA" \
  -H "Authorization: Bearer qrly_live_0f3c…"

Upsert a batch of rows, as a dry run first

Table writes take JSON or text/csv and read PostgREST-style Prefer headers. Run it once with tx=rollback to see what would happen, then again without. max-affected is the guard that stops a bad filter turning into a bad afternoon.

curl -sS -X POST "https://bi.example.com/api/data/7/dim_customer?on_conflict=customer_id" \
  -H "Authorization: Bearer qrly_live_0f3c…" \
  -H "Content-Type: application/json" \
  -H "Prefer: resolution=merge-duplicates, missing=default, return=representation, max-affected=5000, tx=rollback" \
  -d '[{"customer_id":"C-1001","name":"Acme GmbH","segment":"enterprise"},
       {"customer_id":"C-1002","name":"Bruges Logistics","segment":"mid-market"}]'

Call a stored procedure and shape the SETOF result

Named parameters go in the body or the query string. If the function returns a set, the result is shaped with the same vocabulary you would use against a table: a projection, an ordering with explicit null placement, a window and filters drawn from ten operators. Function and column names are validated against a safe-identifier pattern; every value is bound, never interpolated.

curl -sS "https://bi.example.com/api/rpc/7/monthly_margin?\
select=month,margin_pct&order=month.desc.nullslast&limit=12&margin_pct=gte.0.18" \
  -H "Authorization: Bearer qrly_live_0f3c…"

Connect a model over MCP

The MCP server speaks JSON-RPC over POST /mcp with an SSE stream on GET and session termination on DELETE. Sessions are bound by an Mcp-Session-Id header; the stream sends an endpoint event, a 25-second heartbeat and times out at 30 minutes. The admin page carries ready-made connection snippets for Claude, Python, ChatGPT and Slack, so this is usually copy-and-paste rather than typing.

curl -sS -X POST https://bi.example.com/mcp \
  -H "Authorization: Bearer qrly_live_0f3c…" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

# → qrly_ask, qrly_query, qrly_list_dashboards, qrly_get_dashboard,
#   qrly_search, qrly_get_schema, qrly_get_question, qrly_anomalies
01 / Scopes

A token can only ever narrow, never widen

The usual bug in BI APIs is a token that becomes a superuser by accident: a service account is created for one integration, granted a broad scope because scoping was fiddly, and then quietly becomes the credential every script in the company uses. From that point the API has a different permission model from the product.

In Qrly, token scopes are checked in addition to the creator's collection permissions. A data:read token created by an analyst who cannot see the finance collection still cannot see the finance collection. Revoking that person's access revokes it everywhere, including through every token they ever minted. There is one permission model, not two.

02 / MCP

Read-only, and no new credential type

Two decisions make the MCP server something a security team can approve. First, it publishes no write tools at all — eight read tools and nothing else, so the worst outcome of a confused model is a query it should not have run rather than a table it should not have changed. Second, it authenticates with the existing qrly_* API tokens rather than inventing an MCP credential, which means a client cannot widen its own scope through tool arguments.

The protocol handling is defensive in the same spirit: everything outside initialize, initialized, ping, tools/list and tools/call is refused with method-not-found, so clients stop advertising resources, prompts and sampling. The implementation never throws, because a thrown exception would tear down the event stream rather than return an error.

03 / The explorer

Try-it-out as a raw HTTP message

Qrly publishes its OpenAPI document through springdoc but does not ship Swagger UI. The explorer is Qrly's own page: an operation tree rendered from the spec, per-operation persistence, a body-example and media-type picker, multipart editing with a file picker, format and reset-to-example, and an Authorize dialog that takes a pasted bearer token or mints a short-lived session token for your own account.

The interesting part is the try-it-out. Instead of a form of labelled inputs, you edit one raw HTTP message seeded with every parameter and with path parameters already substituted. You are looking at the request you are about to send — which is also the request you will paste into a bug report. Code samples generate in eleven languages: cURL, Java, C#, Rust, Go, C, C++, COBOL, Fortran, RPG and PHP. The explorer is superuser-only.

04 / Tokens

Hashed at rest, shown once, rotatable as a set

API tokens are qrly_live_<32 hex>, SHA-256 hashed at rest and displayed exactly once at creation. Nobody — including an administrator, including a compromised database dump — reads a token back out of Qrly afterwards. Each token is scoped, and each carries its own token-bucket rate limit, so a runaway script degrades its own integration rather than the platform.

Repeated authentication failures trigger a per-IP lockout. And when something does go wrong, there is a rotate-all action, because the realistic incident is not "one token leaked and we know which" but "something leaked and we are not sure what".

Can an API token see more than the person who created it?

No. Token scopes are checked in addition to the creator's collection permissions, not instead of them. A token with the data:read scope created by a user who has no access to a collection still has no access to that collection. Scopes narrow what a token may do; they never widen what its creator could do.

What output formats does the Data API return?

Four: json (the default), csv, ndjson and parquet. NDJSON is streamed, which is what makes large result sets usable from a client that does not want to buffer the whole answer. Parquet means a data engineer can pull a saved question straight into a columnar pipeline without a conversion step in the middle.

Does the MCP server let an AI agent write to my database?

No. The MCP surface is read-only and no write tools are published, by design. It exposes eight tools: qrly_ask, qrly_query, qrly_list_dashboards, qrly_get_dashboard, qrly_search, qrly_get_schema, qrly_get_question and qrly_anomalies. Write paths exist elsewhere in the API, behind FULL data access, but they are deliberately not reachable from MCP.

How does the MCP server authenticate?

With the existing qrly_* API tokens — there is no new credential type. That matters because it means a client cannot widen its own scope through tool arguments: whatever the token could already do is the ceiling. MCP is additionally gated by an mcp_enabled setting and capped by a max-concurrent-sessions limit, with per-session tracking and a force-close action in the admin page.

Can I write rows back to a database through the API?

Yes, with FULL data access on the connection. POST /api/data/{connectionId}/{tableName} accepts JSON or text/csv and honours PostgREST-style Prefer headers: resolution=merge-duplicates with ?on_conflict= for upsert, resolution=ignore-duplicates for DO NOTHING, missing=default, return=representation for RETURNING, max-affected=N as a blast-radius guard, and tx=rollback for a dry run that reports what would have happened and then rolls back.

Is there an interactive API explorer?

Yes, and it is Qrly's own page rather than Swagger UI — springdoc publishes the document only. It renders an operation tree from the spec, and its try-it-out is one editable raw HTTP message seeded with every parameter and with path parameters substituted, so you are editing the request you are about to send rather than filling in a form. It generates code samples in eleven languages: cURL, Java, C#, Rust, Go, C, C++, COBOL, Fortran, RPG and PHP. It is superuser-only.

A BI platform your scripts can use

Parquet out, PostgREST semantics in, and an MCP server that cannot widen its own scope. Self-hosted. Made in Belgium.