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.
Four distinct surfaces with four distinct permission requirements. The separation is the point — the read path and the write path do not share a door.
data:read scopejson, csv, ndjson or parquettext/csvPrefer: handling/mcp, SSE stream, session termination2024-11-05, tools onlyqrly_* API tokensmcp_enabled, capped by max sessionsPermissions are the interesting column. The rule underneath all of it: token scopes are checked in addition to the creator's collection permissions, never instead of them.
data:read scope explicitly?columns= restricts the setmerge-duplicates + on_conflict, or ignore-duplicatesPrefer: tx=rollbackmax-affected=N as a blast-radius guardqrly_live_<32 hex>, SHA-256 at rest, scoped/live · /ready · /metricsAuthentication is a bearer token in a header. There is no session dance, no CSRF token to fetch first and no SDK you are obliged to install.
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®ion=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®ion=EMEA" \
-H "Authorization: Bearer qrly_live_0f3c…"
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"}]'
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…"
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
Each of these is a place where the obvious implementation is the wrong one, and Qrly took the other path deliberately.
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.
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.
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.
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".
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.
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.
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.
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.
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.
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.
Parquet out, PostgREST semantics in, and an MCP server that cannot widen its own scope. Self-hosted. Made in Belgium.