data-platform-mcp.

Model Context Protocol server

Ask your warehouse a question.
Not your wallet.

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.

read-only pip install data-platform-mcp Python 3.11+ MIT

The point of it

Three defences, because they fail differently

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.

1
Dry run, always.

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.

2
A costly query stops and asks.

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.

3
A hard cap nothing can bypass.

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

Two things a column list will not tell you

A date-shaped column is not a partition

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.

Dead tables still answer queries

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.

…and something disabled them

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

Discovery is free. Only one tool costs anything.

ToolPurposeCost
list_environmentsWhich warehouses are configured, and the defaultfree
list_datasetsDatasets in the projectfree
list_tablesTables and views in a datasetfree
get_table_schemaColumns as dotted paths, partitioning, size, freshnessfree
check_table_freshnessWhen each table was last writtenfree
list_scheduled_queriesWhat writes a table, and whether it is disabled or failingfree
get_scheduled_queryOne query's SQL, destination and recent runsfree
run_queryA validated, read-only SELECTscans 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

Read-only as a property of the identity

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

One server, several warehouses

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

Two pastes, once

Install and authenticate

# 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 Code

claude mcp add bigquery --env BQ_PROJECT=my-warehouse \
  -- uvx data-platform-mcp

Claude Desktop

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

Tests, and evals for the things tests cannot see

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.