The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the BigQuery Data Platform listing page.
A read-only Model Context Protocol server over
Google BigQuery. It lets an AI client (Claude Code, Claude Desktop, …) answer
plain-language data questions by discovering schema and running SELECT queries.
The AI does the natural-language → SQL translation; this server just safely executes against BigQuery under your own Google credentials.
data-platform-mcp| Tool | Purpose | Cost |
|---|---|---|
list_datasets | List datasets in the project | free |
list_tables | List tables/views in a dataset | free |
get_table_schema | Columns (nested paths expanded), partitioning, size, row count | free |
check_table_freshness | When each table was last written — catches stale sources | free |
list_environments | Which BigQuery environments are configured, and the default | free |
list_scheduled_queries | Which scheduled query writes a table, and whether it is disabled or failing | free |
get_scheduled_query | One query's SQL, destination and recent runs | free |
list_code_assets | Notebooks, saved queries and data canvases in BigQuery Studio | free |
get_code_asset | One notebook or saved query's body, notebook outputs stripped | free |
find_code_assets_using_table | Which notebooks/saved queries read a table | quota, not $ |
run_query | Run a validated, read-only SELECT and return rows | scans data |
Only run_query costs anything, so the discovery tools are the ones to spend
first. Two of them exist to prevent specific, repeated mistakes:
get_table_schema reports partitioning from table metadata, never from
column names. A table with a partition_date column may not be partitioned
— in which case no WHERE clause reduces the scan and every query reads the
whole table. The response flags this explicitly when the table is large.check_table_freshness finds tables that stopped being written to without
being dropped. Those return stale data rather than an error, which is the
failure mode nobody notices.find_code_assets_using_table answers the other half. The scheduled-query
tools say what writes a table; this says who reads it, which is the
question before a schema change. It is also the one discovery tool that is
not free: it opens every asset it considers, spending Dataform read quota,
so it is capped and reports how much of the project it actually covered. A
result is evidence about the assets scanned, never proof that nothing else
uses the table — assets it could not read are listed separately rather than
counted as misses.list_scheduled_queries says why. A stale table is usually a scheduled
query that was disabled or is failing, and that lives in a different API
(BigQuery Data Transfer) needing roles/bigquerydatatransfer.viewer. Without
that role the two tools return an error naming it and everything else works
normally. Most scheduled queries declare no destination because they write
with DDL, so the target is read out of the SQL and reported as
writes_to_from_sql — a heuristic, labelled as one.These are not BigQuery resources. BigQuery Studio stores each code asset as a
Dataform repository holding a single file, which means a third API and a
third permission — roles/dataform.viewer — beyond BigQuery and the Data
Transfer Service. Without it the three tools return an error naming the role
and everything else works normally. They are also invisible in the Dataform UI,
so nothing in the console hints that this is where they live.
Two things about that storage are worth knowing before you configure it:
Code assets are regional, and it is not the dataset region. Dataform
rejects multi-regions, so a platform whose datasets are US keeps its
notebooks in something like us-central1. No configuration is needed:
when location is a multi-region the server probes the regions inside it,
uses the one holding the assets, and says so — a multi-region cannot simply
be inherited, because using it is guaranteed to fail rather than merely
likely to. Pin code_asset_location (or BQ_CODE_ASSET_LOCATION) to skip
the probing; an explicit value is never second-guessed, so a wrong one
returns an empty list rather than an error. Every result echoes back the
location it read.
Notebook bodies are mostly output. Across 52 real notebooks, cell outputs were 77% of the bytes — one was 1.44 MB of file for 80 KB of code. Outputs are stripped before anything is returned, and the saving is reported so you can see that what is missing was rendered charts rather than logic.
Reads are quota-limited by volume rather than by concurrency, and the quota refills over tens of seconds. Exhaustion is retried with backoff and, if it persists, reported as something to retry shortly rather than as a failure.
One server answers questions about several targets — a warehouse and its
staging copy, or two regions of the same project. Every tool takes an optional
environment; omitting it uses the default.
See config.toml.example for every setting, or set
BQ_MCP_ENVIRONMENTS to the same structure as JSON. A single BQ_PROJECT
still works unchanged — it becomes one environment named default.
An environment can be named by its own name, an alias, the built-in shorthands
(prod, stg, dev, live) or its project id. An unknown name is an
error naming the valid options, never a silent fall back to the default: a typo
that answered a production question from staging would be invisible in the
reply. Every result echoes back the environment it came from.
Regions are why this matters most here. 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.
The SELECT-only guard and the readOnlyHint annotations are promises about
this code. Pointing the server at a service account that holds 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 those two roles, and gives you
roles/iam.serviceAccountTokenCreator on it so the server can impersonate it.
Add --dry-run to see the commands first; it is safe to re-run.
With --datasets, the dataset allowlist stops being an if statement in this
process and becomes a grant Google enforces.
Terminal.app is not Xcode. It ships with every Mac. What does not ship is
the Xcode Command Line Tools, and an analyst's laptop usually has neither
those nor Homebrew. Nothing here needs them — but it is easy to trip over by
accident, because git, make, clang and the stock /usr/bin/python3 are
stubs for that bundle: running any of them pops a system dialog offering to
install about a gigabyte of developer tooling.
None of the commands below invoke one. They use only utilities macOS already
has — curl, tar, sh, uname — because both installs are self-contained:
| Install | Why it needs nothing else |
|---|---|
uv | A standalone binary. Its installer never mentions Python, and it downloads its own to run the server. |
| Google Cloud CLI | The macOS tarball bundles its own Python (.install/bundled-python3-unix-darwin-*). |
The whole terminal requirement is the three blocks below, once.
Typically /Users/<you>/.local/bin/uvx.
Pick the build for your chip — uname -m prints arm64 for Apple Silicon,
x86_64 for Intel:
Avoid brew install --cask google-cloud-sdk: Homebrew itself requires the
Command Line Tools, which is the thing this section exists to avoid.
This writes a credentials file that the Google libraries read directly.
gcloud does not need to be on your PATH afterwards — it is needed once,
here. That is why a GUI-launched Claude Desktop can query BigQuery even though
it cannot see your shell.
Your account needs BigQuery Job User on the project the query runs in, and BigQuery Data Viewer on each dataset it reads — often a different project.
Then register with your client: Claude Desktop or Claude Code.
If even that is too much, an admin can do the credential half centrally and
the analyst installs nothing but uv — skipping step 2 and step 3 entirely.
(Nothing about this is macOS-specific; it works the same on any OS.)
The analyst saves that file and points the config at it:
The trade-off is real and worth stating. A key file is a long-lived
credential sitting on a laptop, where gcloud auth application-default login
issues short-lived tokens tied to a person. It is defensible here because the
account created by setup --datasets can only read the datasets you name, and
because a key can be revoked centrally the moment a laptop is lost — but it is
strictly weaker, and it is a per-analyst secret, so do not put it in a shared
config file or a repository.
The same two installs, with no equivalent of the Command Line Tools problem:
Then check it worked and register with your client.
Not verified end to end. The download URLs and install locations below were checked; the flow itself has not been run on a Windows machine. CI tests Linux only. Treat this as a careful derivation, not a tested recipe — and please open an issue if a step is wrong.
In PowerShell:
This installs uv.exe and uvx.exe into %USERPROFILE%\.local\bin. Confirm
the exact path, because the Desktop config needs it in full:
Download and run GoogleCloudSDKInstaller.exe. Leave "Bundled Python" ticked — it is what lets the SDK run without a separate Python install, the same property the macOS tarball has.
In a new PowerShell window, so it picks up the updated PATH:
This writes credentials to
%APPDATA%\gcloud\application_default_credentials.json, which the Google
libraries read directly — so gcloud need not be on PATH afterwards.
%APPDATA%\Claude\claude_desktop_config.json — create it if absent.
Backslashes must be doubled in JSON, and the path must be absolute:
Replace C:\Users\YOU\... with what (Get-Command uvx).Source printed, with
each \ written as \\. Then fully quit and reopen Claude Desktop.
If it fails, the logs are in %APPDATA%\Claude\logs\. ENOENT there means the
command path is wrong or its backslashes were not doubled — the same failure
macOS has, with one extra way to get it wrong.
Each person runs their own local copy. Queries execute under their own BigQuery/IAM permissions, so existing access controls decide who can see what.
Install the prerequisites for your platform first — macOS, Linux, Windows — then come back here.
The package is published as data-platform-mcp (bigquery-mcp was already
taken on PyPI by an unrelated project). No checkout is needed — the client can
fetch and run it directly:
From source, for development:
Either way you get a data-platform-mcp command, which is what the client runs.
Covered in the platform sections above: macOS step 3, or the
equivalent gcloud auth application-default login elsewhere. Queries then run
under your own credentials via
Application Default Credentials.
Using a service-account key instead? Set GOOGLE_APPLICATION_CREDENTIALS
to its path — but set it where the MCP server is launched, not in a shell:
The client spawns the server as a subprocess with only the environment its config declares. Exporting the variable in a terminal has no effect on it — that is a distinct failure from having no credentials at all, and it looks identical from the outside.
Checks credentials, job permission, dataset visibility and — the one that
catches people — dataset regions. BigQuery cannot query a dataset from a
different location, and its own error names neither the location it wanted nor
the one the dataset is in, so it reads as a missing table. doctor names both:
A dataset in another region is a warning; one on your BQ_DATASET_ALLOWLIST is
a failure, because no tool call could ever read it.
Replace your-gcp-project with your GCP project ID.
Claude Code — once published:
From a source install, point at the checkout instead (replace
/abs/path/bigquery-mcp):
Claude Desktop — see the dedicated section below; it needs absolute paths.
"Which datasets are available? In the
salesdataset, how many rows does theorderstable have?"
Most of a data team will use Desktop rather than the CLI, and it has one failure mode the CLI does not.
Claude Desktop does not inherit your shell PATH. It launches from the
Finder, so uvx, python and anything installed by Homebrew or uv are
invisible to it. A config that says "command": "uvx" fails with ENOENT —
the server never starts, and the error names the command rather than the
reason. Every path in this file must be absolute.
What does not break: credentials. Application Default Credentials are a
file that the Google libraries read directly, so gcloud does not need to be
on PATH for queries to work — it is only needed once, in a terminal, to
create that file. Verified by running this server with an entirely empty
environment: the query succeeded.
Do the platform setup first — macOS (two pastes, no Xcode
tools needed) or Linux, Windows. You need two things
from it: the absolute path that which uvx printed, and a completed
gcloud auth application-default login.
Claude Desktop's Settings → Connectors lists hosted connectors; a local server like this one is not added there. It goes in a JSON file instead:
Settings → Developer → Edit Config opens it. Or edit it directly:
| OS | File |
|---|---|
| macOS | ~/Library/Application Support/Claude/claude_desktop_config.json |
| Windows | %APPDATA%\Claude\claude_desktop_config.json |
The file usually already exists and holds your Desktop preferences. Add
mcpServers as one more top-level key — do not replace the file, or you will
lose those settings. If it genuinely does not exist, create it with just the
block below.
Replace /Users/YOU/.local/bin/uvx with what which uvx printed. On Windows
the path looks like C:\\Users\\YOU\\.local\\bin\\uvx.exe, and backslashes must be
doubled in JSON.
Merged into a file that already has settings, it looks like this — mcpServers
sits alongside whatever is there, not instead of it:
Check it still parses before restarting — a stray comma disables every server, silently:
Fully quit and reopen — reloading the window is not enough. The server appears under the tools icon in the message box.
Rather than growing the JSON, put the environments in
~/.config/data-platform-mcp/config.toml (see
Environments). The Desktop config then needs no env block at
all, and is identical on every machine:
This is the better shape for a team: one config file to share, and the JSON stops carrying project ids.
Desktop hides the reason, so check in this order:
~/Library/Logs/Claude/mcp*.log. ENOENT or
"command not found" there means the command path is wrong — go back to
which uvx.SELECT / WITH statements run — no writes, DDL, or DML.BQ_WARN_BYTES
(default 1 GB) does not run. It returns status: "confirmation_required"
with the estimated scan size and dollar cost so the client can ask before
proceeding. Re-call with confirm_expensive=true to run it.BQ_MAX_BYTES_BILLED (default 5 GB) never run,
even with confirmation — a runaway-cost backstop.SELECT statement, a disallowed dataset, a query over the hard cap —
arrives with MCP's isError set, so it cannot be mistaken for a result.
confirmation_required is the deliberate exception: it is a normal result,
because the agent is meant to relay it and come back.run_query stops adding rows once the
serialised response reaches ~40k characters and sets stopped_for_size, so a
wide result cannot quietly consume the whole context window. A partial answer
always says that it is partial.| Var | Default | Meaning |
|---|---|---|
BQ_MCP_ENVIRONMENTS | (none) | JSON map of environment name to settings. Takes precedence over the config file. |
BQ_MCP_DEFAULT_ENVIRONMENT | (safest, else first) | Environment used when a call omits environment. Prefers a staging/dev environment when unset. |
BQ_MCP_CONFIG | ~/.config/data-platform-mcp/config.toml | Path to the TOML config file |
BQ_IMPERSONATE_SERVICE_ACCOUNT | (none) | Read-only service account to impersonate |
BQ_PROJECT | (ADC project) | GCP project ID whose BigQuery datasets you query. Falls back to the project associated with your credentials; tools error with instructions if neither is set. |
BQ_LOCATION | US | BigQuery location |
BQ_WARN_BYTES | 1073741824 (1 GB) | Above this, ask the user to confirm before running |
BQ_MAX_BYTES_BILLED | 5368709120 (5 GB) | Hard per-query scan cap — never exceeded |
BQ_COST_PER_TIB_USD | 6.25 | On-demand price used to render the cost estimate |
BQ_ROW_LIMIT | 200 | Default rows returned |
BQ_DATASET_ALLOWLIST | (empty = all) | Comma-separated dataset IDs |
BQ_MCP_TRANSPORT | stdio | stdio (subprocess) or http/sse (serve over network) |
BQ_MCP_HOST | 127.0.0.1 | Bind host when transport is http/sse. run-http.sh overrides this to 0.0.0.0 so containers can reach it — see the security note below. |
BQ_MCP_PORT | 8765 | Bind port when transport is http/sse |
BQ_MCP_AUDIT_LOG | ~/.local/state/data-platform-mcp/audit.jsonl | JSONL record of every tool call. off disables it. SQL text is never written — only a hash and length. |
BQ_MCP_LOG_LEVEL | INFO | Verbosity of the stderr log |
By default the server speaks stdio — the right choice when a client spawns it (Claude Code, Claude Desktop), and what the Quick start above uses.
To reach the server from a remote or containerized client instead of having each client spawn its own, run it over HTTP:
Clients then connect by URL (Claude Code):
⚠️ Security: the HTTP endpoint has no authentication, and every query runs under the host's ADC credentials — not the connecting user's. Anyone who can reach the port gets full read access to
BQ_PROJECTunder your identity. Only expose it on a trusted network (bindBQ_MCP_HOST=127.0.0.1and use an SSH tunnel/VPN, or an authenticating proxy). See docs/nanoclaw.md for the containerized-client setup this mode was designed for.
For server deployments, point GOOGLE_APPLICATION_CREDENTIALS at a
service-account key with BigQuery Data Viewer + Job User roles instead of using
personal ADC.
The suite needs no credentials and no network — every test runs against
fakes in tests/conftest.py, so it is deterministic and free. Layers:
| File | Covers |
|---|---|
test_protocol.py | The MCP contract through a real in-memory client session: tool set, read-only annotations, generated schemas, isError on refusal |
test_query_guard.py | The cost gate — what runs, what is refused, what is handed back to the user, and what the caller is told about limits |
test_payload_shape.py | Response shapes against fake tables, including the partitioning trap and nested-field flattening |
test_observability.py | The audit trail, and the promise that SQL text never reaches it |
test_diagnostics.py | doctor's report, including the region and allowlist failures it exists to catch early |
test_environments.py | Routing between environments, per-environment limits, and impersonation targeting |
test_config.py | The environment registry, aliases, the TOML file, and the missing-project error that used to be an import-time crash |
test_errors.py | Auth failures carry the command that fixes them |
test_formatting.py | The size and cost figures a user is asked to approve |
test_eval_scoring.py | The eval scorer, fed the trajectories each case exists to reject |
Two further layers need live credentials, so they are not part of pytest:
evals/measure.py records what a client actually receives from each tool, and
evals/tool_use_evals.py 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.
See evals/README.md for what each case catches and
evals/BASELINE.md for what the last run measured. Tool
and server descriptions are the highest-leverage thing to change in this
server, and nothing except an eval tells you they need changing.
A suite that passes on its first run proves nothing, so the guarantees above
were checked by breaking them: reverting refusals to error-shaped returns,
logging raw SQL, guessing partitioning from column names, removing the response
budget, dropping functools.wraps from the audit wrapper, letting confirmation
bypass the hard cap, silencing stale-table detection, and removing the
allowlist check. Each one fails the suite.
uvx resolves the latest version on its first run only, then reuses the
cached environment indefinitely. A server left as ["data-platform-mcp"] keeps
running the build it first downloaded: tools added by a later release are
simply absent, which reads as "this server cannot do that" rather than as an
upgrade that has not landed. Pin @latest so every start re-resolves:
Then fully quit and reopen the client. MCP servers are spawned at client startup, so a reload leaves the old process running.
To upgrade a one-off without editing config, uv cache clean data-platform-mcp
(or uv tool upgrade data-platform-mcp if it was installed with
uv tool install). list_environments reports server_version, so you can
confirm what is actually running from inside the conversation.
Version numbers live in two files and CI refuses a tag where they disagree — a
mismatch would ship a tag pointing at different code than the package claims.
(__version__ is read from the installed distribution, so it cannot drift.)
The tag triggers .github/workflows/release.yml, which verifies the versions
agree, builds, publishes to PyPI via Trusted Publishing, then registers the
release with the MCP registry. Neither step stores a token: PyPI uses OIDC
from this repository and the pypi environment, and the registry uses GitHub
OIDC. Both need one-time setup before the first release:
deBilla/bigquery-mcp, workflow release.yml, environment pypi.pypi environment in repository settings..github/workflows/ci.yml runs on every push and pull request:
| Job | Checks |
|---|---|
test | The suite on Python 3.11, 3.12 and 3.13 — with no GCP credentials on the runner, which is the point |
safety | No credential-shaped strings in tracked files; .env/.mcp.json untracked; no mutating BigQuery client calls anywhere in src/ |
package | Builds, twine checks, asserts no local config leaked into the sdist, then installs the wheel into a clean venv and drives the real protocol — 5 tools, every one annotated read-only and documented, instructions intact |
The last one is the important one: it catches a package that installs cleanly and dies on its first request, which is a failure no unit test sees.
MIT — see LICENSE.