Model Context Protocol server
A read-only BigQuery server for AI agents. It turns plain-language
questions into SQL, and puts a cost gate in front of every query — so a careless
SELECT * is priced and refused instead of billed.
The point of it
An agent writing SQL against a warehouse is an agent spending money it cannot see. Every query here is priced before it runs, and the expensive decision is handed back to the person who pays for it.
Validates the SQL and prices it before a byte is scanned. It also reports the
statement type — which is how anything that is not a SELECT is refused
reliably, rather than by pattern-matching the text.
Above the warning threshold the query does not run. The agent gets back
status: "confirmation_required" with the size and the dollar figure, and
is told to report it and wait. confirm_expensive records the user's
decision, not the agent's.
Above it, the query never runs — confirmed or not — and
maximum_bytes_billed is set on the job as a second line of defence in
case the estimate was low.
# a real refusal, against a 6.7 TiB unpartitioned table
This query would scan 5.09 TiB ($31.79), above this server's
5.00 GiB hard limit, so it did not run. Select fewer columns, or
filter on a partition column if get_table_schema shows the table
has one.
Note that this cap is a guardrail on this tool, not a BigQuery
limit — a genuinely large query can be run directly with the
google-cloud-bigquery Python client, which has no such cap.
Refusals arrive as protocol errors, so a client can tell them from results.
confirmation_required is the deliberate exception: it is a normal
result, because the agent is meant to relay it and come back.
Beyond the schema
Partitioning is read from table metadata, never inferred from a name. Two tables
in one dataset can share 218 identical columns and have opposite cost behaviour —
one partitioned on partition_date, its twin not partitioned at all.
Guessing turns a filtered query into a multi-terabyte scan.
A table that stopped being written to returns stale rows rather than an error,
which is the failure nobody notices. check_table_freshness reads
__TABLES__ — zero bytes scanned — and names anything untouched for
over 30 days.
A stale table is usually a scheduled query that was switched off or is failing.
list_scheduled_queries says which one writes a table and what state it
is in. Most declare no destination because they write with DDL, so the target is
read out of the SQL.
Nested records are expanded to dotted paths and flagged repeated, so
UNNEST can be written rather than guessed: a GA4-shaped table reports
32 top-level columns and 218 fields.
Eight tools
| Tool | Purpose | Cost |
|---|---|---|
list_environments | Which warehouses are configured, and the default | free |
list_datasets | Datasets in the project | free |
list_tables | Tables and views in a dataset | free |
get_table_schema | Columns as dotted paths, partitioning, size, freshness | free |
check_table_freshness | When each table was last written | free |
list_scheduled_queries | What writes a table, and whether it is disabled or failing | free |
get_scheduled_query | One query's SQL, destination and recent runs | free |
run_query | A validated, read-only SELECT | scans data |
Responses are size-bounded as a whole, not by row count: 500 wide rows come back as
the 19 that fit, flagged stopped_for_size, rather than a megabyte of JSON
in the context window. A partial answer always says it is partial.
Security
The SELECT-only guard and the readOnlyHint annotations are
promises about code. Pointing the server at a service account holding only
roles/bigquery.jobUser and a dataset-scoped
roles/bigquery.dataViewer makes it a fact about the credentials —
enforced by IAM whatever the code does, and whatever your own roles allow.
# creates the account, grants exactly those two roles, # and lets you impersonate it — no key file to manage data-platform-mcp setup --project my-warehouse --datasets sales,events
With --datasets, the dataset allowlist stops being an if
statement in this process and becomes a grant Google enforces.
Query text is never written to the audit log — only a hash and a
length. A WHERE clause routinely carries the user ids and emails it
filters on, and an audit file that copies them is a second uncontrolled home for
that data.
Environments
Every tool takes an optional environment. An unknown name is an error
naming the valid options, never a silent fall back — a typo that answered a
production question from staging would be invisible in the reply. Every result
echoes back where it came from.
# ~/.config/data-platform-mcp/config.toml default_environment = "warehouse" [environments.warehouse] project = "my-warehouse" impersonate = "data-platform-mcp-ro@my-warehouse.iam.gserviceaccount.com" dataset_allowlist = ["sales", "events"] [environments.central] # same project, different region project = "my-warehouse" location = "us-central1"
Regions are why this matters. BigQuery cannot query across
locations, and its error for trying names neither location — so it reads as a missing
table. One environment per location; doctor reports which datasets are
where.
Quickstart
# macOS / Linux — no Xcode Command Line Tools needed curl -LsSf https://astral.sh/uv/install.sh | sh gcloud auth application-default login # confirm credentials, roles, datasets and regions in one pass BQ_PROJECT=my-warehouse uvx data-platform-mcp doctor
claude mcp add bigquery --env BQ_PROJECT=my-warehouse \ -- uvx data-platform-mcp
Desktop launches from the Finder and inherits no shell PATH, so the
command must be an absolute path — which uvx prints it. Credentials are
unaffected: they are a file the Google libraries read directly.
{
"mcpServers": {
"bigquery": {
"command": "/Users/you/.local/bin/uvx",
"args": ["data-platform-mcp"],
"env": { "BQ_PROJECT": "my-warehouse" }
}
}
}
Full per-platform instructions, including Windows and a no-terminal path for analysts, are in the README.
How it is checked
The suite runs with no credentials and no network — every test uses fakes, so it is
deterministic and free. Above it sit two layers that need a live warehouse:
measure.py records what a client actually receives from each tool, and a
set of tool-use evals asks real questions through the Claude CLI and scores the
trajectory from the server's own audit log — which tool ran, against which
environment, with which arguments.
A suite that passes on its first run proves nothing. Each guarantee here was checked by deliberately breaking it, and each eval case is fed the trajectory it exists to reject — the wrong warehouse, a query before a schema read, a self-approved spend, a fluent answer with no tool call at all.