MCP
SQLDesk answers questions about your warehouse over the Model Context Protocol. An MCP client — Claude Code, or anything else that speaks it — gets a handful of tools backed by a catalog built from your schema and the queries your team has already written.
Turning it on
SQLDESK_FEATURE_AI=true
Off by default. Until it is set, the endpoint answers 404 and the Admin page says so.
Filling the catalog
The tools answer from a catalog rather than from a live schema read. The scheduler rebuilds it once
a day (SQLDESK_CATALOG_HARVEST_SCHEDULE, in hours; 0 turns it off), and until the first
harvest the context tools have nothing to say — so run one yourself after turning MCP on. The
commands run in the server container, where manage is /app/manage.py:
manage ai harvest # every data source
manage ai harvest --data-source "Warehouse"
manage ai context "revenue by region" # what a client would be told
# Compose
docker compose -f compose.prod.yaml exec server /app/manage.py ai harvest
# Kubernetes
kubectl exec deploy/sqldesk-server -- ./manage.py ai harvest
Descriptions come with it where the warehouse carries them — COMMENT ON in
Postgres, table_comment and column_comment in MySQL. Where it carries none,
write them under Admin → Catalog, which lists the most-queried tables first and
can show only the ones still missing a sentence. A data source also has a description of its own: one
paragraph of standing guidance that reaches every answer about it.
Harvesting reads each source's schema and parses every saved query, which is where the useful part comes from: which tables anyone actually uses, which columns anyone selects, and which joins anyone writes. A warehouse with three thousand tables and no usage data can only be ranked alphabetically.
Only queries that have run in the last week teach it anything
(SQLDESK_CATALOG_USAGE_WINDOW_HOURS, 168 by default; 0 for every saved query however
old). Recency is when a query last ran rather than when it was last edited, because editing is a poor
proxy for mattering: a dashboard that refreshes every morning and has not been touched in a year is
the most important thing in the warehouse, and a query somebody tweaked last week and never ran is
not. A query that has never run is never mined at all — nothing has ever depended on it.
What exists is unaffected: tables and columns come from the schema, so nothing disappears from the catalog. It is the usage — the ranking, the join counts, the proposed measures — that tracks what people are doing now.
Run it by hand when the schema has changed and you would rather not wait for the next scheduled one.
Daily is plenty otherwise, because what it reads — table definitions and saved SQL — does
not change by the minute. It is safe to run while people are working: it reads the schema, reads
queries.query_text, and writes only to its own catalog tables.
Keeping meaning in git
Structure is the warehouse's to state and usage is mined from saved queries, but what a table means is written by people — and the place to review what people wrote is the place you already review things. So the catalog is the cache and a directory of YAML is the record:
What you type in the browser goes straight into SQLDesk's own database and is live at once — the next answer includes it. Git is a separate, deliberate step, and nothing is committed for you:
manage ai export /app/semantic # catalog → files, then commit them
manage ai import /app/semantic # files → catalog, on deploy
Point SQLDESK_SEMANTIC_DIR at a directory on the host — a checkout of your model
repo, if you like — and it is mounted at /app/semantic, so what the export writes
appears where you can commit it:
docker compose -f compose.prod.yaml exec server \
/app/manage.py ai export /app/semantic
cd ./semantic && git add . && git commit -m "Describe the orders table"
The directory has to be writable by the container, which runs as uid 1000 rather than as you. On
Docker Desktop the file sharing layer hides that; on Linux it does not, so
chown -R 1000: ./semantic once. The export says so plainly rather than failing
half-written.
There is also a Download YAML button on Admin → Catalog, which sends the same files as a zip. It is for the person writing the descriptions, who often is not the person with access to the container — and a curation step that requires docker is one that does not happen. Unzip it into your repo and commit. Filter to one data source first and only that source is included.
SQLDesk never talks to git. No deploy key, no conflict handling, no worker that needs to reach GitHub — the pipeline that already does those things does them better, and a change to what a metric means becomes a pull request rather than a row somebody altered.
The shape is cube's, one file per table, so the diff is readable and the files are worth something outside SQLDesk:
cubes:
- name: orders
sql_table: orders
description: One row per placed order, net of cancellations.
dimensions:
- name: amount
sql: amount
type: number
description: Gross, before refunds.
measures:
- name: gross_revenue
type: sum
sql: amount
joins:
- name: users
sql: "{CUBE}.user_id = {users}.id"
relationship: many_to_one
Two rules make the round trip safe. Importing never creates anything — a file
naming a table the catalog has not harvested is skipped rather than invented, because a row that is not
in the warehouse has no business in front of a model. And a file cannot redefine what a measure
computes: its sql and type are what the miner found in real queries,
so a file can agree with a definition and describe it, but not claim one nobody writes.
A description that arrived from a file is marked as a person's, so the next harvest will not replace it with whatever the warehouse does or does not say.
Where measures come from
You have no data hub, and most warehouses do not. But every dashboard here is built on SQL somebody
wrote and saved, and SUM(amount) AS gross_revenue is a person naming a metric. The
harvester collects those: the alias becomes the name, and the count is how many distinct saved queries
define it that way — four teams agreeing independently is a different proposition from one person
trying it once.
Nothing is believed. Proposals appear under Admin → Catalog → Measures and reach a model only once somebody agrees them, because a metric definition that is merely plausible is worse than none: the wrong revenue number is still a revenue number.
Three states, not two: Agree, Deny, or leave it alone. Denying matters for a duller reason than agreeing — a proposal nobody can reject comes back on the list after every harvest, and a list that repeats itself stops being read. A denied definition keeps its count, because how many people write it is a fact either way, but it never reaches a model and never returns to the worklist. Undo puts it back.
Two things it will not guess. An aggregate across a join is not attributed to either table, and an
expression like SUM(price * qty) is left for a person to write, since neither can be
checked against a column.
Connecting a client
One endpoint, JSON-RPC over HTTP, and two ways to prove who you are.
Signing in (recommended)
Add the endpoint and nothing else. The client calls it, gets a 401 that names where to look, finds the OAuth endpoints, and opens a browser. You sign in the way you always sign in — which is your identity provider where SAML or Google is configured — and the client gets a token.
claude mcp add --transport http sqldesk https://sqldesk.example.com/mcp
Why this rather than a key: an API key is a long-lived secret living outside your identity provider. It is not revoked when somebody leaves, it is invisible to whoever administers your SSO, it is the same string forever, and nothing in SQLDesk can tell you which machines it has been pasted onto. A token expires in an hour, refreshes for thirty days of use, is listed on your profile under Connected apps with when it was last used, and stops working the moment you or an administrator disconnects it. Disabling an account revokes everything it holds.
The consent page names the app and says plainly that SQLDesk cannot vouch for that name: anybody may register a client, and a name is the one thing a phishing client can choose freely. It also says what the app will be able to do, which is read what you can read and nothing else.
What it needs on the server: nothing. It is on wherever MCP is on, and the endpoints answer
404 where it is off, so a client falls back to a key rather than looping. It does need
https — an authorization code or a token crossing a network in the clear is what the
flow exists to avoid. For a local install with no TLS anywhere, set
SQLDESK_MCP_OAUTH_ALLOW_HTTP=true; the server logs a warning while it is on.
An API key
Still accepted, and the right choice for a script or a headless setup. The key is the one on your profile page.
POST https://sqldesk.example.com/mcp
Authorization: Bearer <your SQLDesk API key>
claude mcp add --transport http sqldesk https://sqldesk.example.com/mcp \
--header "Authorization: Bearer <your API key>"
Every call runs as the person who authorized it. The client sees the data sources that user
can see and nothing else — in the catalog and in the saved queries and dashboards it searches, not only
when it runs SQL. Running SQL, EXPLAIN included, needs full access to the data source,
the same as the query editor: a user in a view-only group can look things up but not ask new questions. There is no service account and no way to configure one, which is deliberate:
a tool server with more access than its user is a way to launder permissions.
A ceiling on what a query may cost
A model exploring a warehouse writes queries nobody reviewed. Most are cheap; the expensive ones are
expensive in a way that shows up on a bill or on somebody else’s dashboard, and by the time either
happens the model has moved on. So run_query can be given a limit, and the estimate it is
checked against is the engine’s own — never a guess from the SQL.
| Setting | Applies to |
|---|---|
SQLDESK_MCP_MAX_QUERY_COST | Postgres and its family, from EXPLAIN (FORMAT JSON). Arbitrary units — a sequential page fetch is 1.0 — so the figure is one you arrive at by looking at your own queries. Comparable between queries on one database, which is all it has to be. |
SQLDESK_MCP_MAX_QUERY_BYTES | BigQuery, from a dry run. This is what BigQuery bills on, so it is the one estimate here that is money rather than a proxy for it. |
Both default to 0, which is no limit. A figure we picked would refuse reasonable work on one
warehouse and allow ruinous work on another.
Every other engine is unaffected, and that is deliberate rather than a gap: a cost model
of our own would be a second opinion about a question the engine answers authoritatively, and it would have
to be written again for each of the thirty-five connectors. The same rule covers an
EXPLAIN that fails and a dry run that errors: no answer is never a refusal.
Refusing a query for a reason that is not true is worse than running an expensive one.
A refusal says the estimate, the limit and what to do about it, so the next query the model writes can be narrower rather than another guess.
The tools
Eight, and the order matters. The first question is not "what SQL should I write" but "has somebody already answered this".
Look for work that already exists
find_queries(question)- Saved queries matching, with their descriptions. A saved query carries its author's understanding of the data — which join is the right one, what a status means — and no amount of schema carries that.
find_dashboards(question)- Dashboards matching, with their addresses. Often the useful answer is a page that already exists rather than a new query.
Understand the data
find_context(question)- The handful of tables most likely to answer a question, with columns already pruned to the ones people use, and the joins observed between them. One call, rather than the forty a list-tables-then-get-schema pair provokes.
expand_table(names)- Every column of named tables, when the pruned list is not enough.
list_data_sources()- What this user can read, with each one's dialect.
Check, then run
check_sql(sql)- Parses for the right dialect and reports expensive patterns — a join with no condition,
SELECT *on a columnar table, a filter that defeats partition pruning. Nothing is executed. This says whether the shape is wrong. explain_query(sql, data_source)- The engine's own plan, with its own cost estimates. This says what it will cost,
which is the number that decides whether running it is reasonable. It is never
EXPLAIN ANALYZE, which runs the statement it describes. run_query(sql, data_source)- Executes one read —
SELECT,WITH,SHOW,DESCRIBE— and returns up to 1000 rows — the same ceiling a person clicking Execute gets, applied by the runner that knows the dialect. It runs on a worker, as the user whose key it is, so it appears in Admin → Overview beside every other running query — labelled via MCP, because a model asking is not a person waiting — and can be cancelled there.
Nothing here has more access than the person whose key it is. Every data source check is the same one the rest of the application makes, and there is no service account and no way to configure one — a tool server with more access than its user is a way to launder permissions.
Only reads
run_query and explain_query refuse anything but a single read: a write,
DDL, a second statement after a semicolon, a SELECT whose WITH deletes,
SELECT ... INTO. Writes stay possible in the editor, where a person reads them first.
This stops a model doing damage by accident. It is not what stops it on purpose: the
statement is checked by parsing it, and a function with side effects called from a
SELECT looks like any other function. What stops that is the account SQLDesk connects
with — give each data source a read-only database user, which is good practice for a BI tool
in any case.
Limits
One request waits at most ten seconds less than the web server's own timeout
(SQLDESK_GUNICORN_TIMEOUT, 60 by default), across every call in it; a query still
running at that point carries on, and the tool says so and where to find it. A batch holds at most
ten messages, SQL at most 100,000 characters, and expand_table takes twenty names at
a time.
A question, end to end
What the sequence looks like in practice, against a real warehouse. Each step is one tool call.
1. find_queries("revenue product")
→ No saved query matches that.
2. find_context("revenue by product")
→ orders(amount numeric, ts timestamp with time zone, region text,
product text, id bigint)
used by 43 saved queries
joined with region_targets (4)
3. check_sql("SELECT product, sum(amount) FROM orders GROUP BY 1 ORDER BY 2 DESC")
→ WARNING: No WHERE clause and no LIMIT
This reads the whole table.
4. explain_query(same SQL)
→ Sort (cost=80.50..80.51 rows=5 width=39)
HashAggregate (cost=...)
5. run_query(same SQL)
→ product revenue
Team 163571
Business 103105
Starter 96446
Step 1 matters more than it looks. The question that has already been answered is answered better than a model will answer it, because the person who wrote it knew which join was right. Step 3 and step 4 are different questions: one is whether the SQL is shaped badly, the other is what the engine thinks it will cost.
Finding the page
Your own name in the top right, then Connect over MCP. It shows the endpoint and
the claude mcp add line for this install, and the key it tells you to use is your own,
from your profile page — so every user can set up a client without an administrator.
Administrators reach the same page under Admin, where it also carries the audit.
The audit
Admin → MCP. Every request leaves a row, including the refused ones — a key that does not work, tried repeatedly, is exactly what an audit is for. Each row records who, when, which method, which tool, the outcome, how long it took, and where it came from.
Arguments are summarised rather than stored: the question is worth keeping, a megabyte of SQL is not.
The page also shows active sessions — clients seen in the last fifteen minutes. This transport holds no connection open, so "connected" can only honestly mean "recently active", and the page says so rather than showing a light that implies otherwise.
What it costs your warehouse
The lookup and context tools cost nothing — they read SQLDesk's own catalog. Only
explain_query, run_query and manage ai harvest reach a data
source at all, and the first of those is a plan rather than a scan. Harvest reads the schema SQLDesk
has already cached for the schema browser, so on a warm cache it touches the warehouse not at all,
and on a cold one it reads table definitions rather than table contents.
Queries issued this way go on the ordinary queue by default, so they compete with dashboards for
workers — and there are always more model queries than there are people, so the people lose.
Set SQLDESK_MCP_QUEUE to a queue name and give that queue workers of its own and they
cannot. The Helm chart does both from mcp.queue, which also renders a worker that takes
only that queue; with Compose, set SQLDESK_MCP_QUEUE=mcp and start the mcp
profile (see Deploying). Either way the ordinary worker takes
mcp last when nothing else does, so the queue is never left without a worker.
If a client cannot connect
- 404 —
SQLDESK_FEATURE_AIis not set. - 401 — the key is missing, wrong, or belongs to a disabled user. It goes in the
Authorizationheader rather than the URL, because a URL is logged by every proxy in between. Check the audit: a refused attempt is recorded. - Tools answer but find nothing — the catalog is empty. Run
manage ai harvest.