bq-guard-mcp
# bq-guard-mcp
An MCP server that gives AI agents BigQuery access through a policy. Every query is parsed,
checked, and dry-run before anything is billed.
Letting an agent query a warehouse directly means trusting it not to scan a 40 TB table, run a
`DELETE`, or read the PII dataset. `bq-guard-mcp` is the one thing the agent talks to. It
exposes five tools, and each request goes through the guards in `bq-guard.yaml`. A refusal
says what to change, so the agent can fix the query instead of failing blind.
```text
run_query("SELECT * FROM analytics.events")
-> Refused by bq-guard policy: the query would scan 3.41 TB, over the 5.00 GB limit;
filter on partition or clustering columns, or select fewer columns
```
## Tools
| Tool | What it does |
|---|---|
| `list_datasets(project?)` | Datasets the policy allows. Denied datasets are hidden. |
| `list_tables(dataset)` | Tables in `dataset` or `project.dataset`. Denied tables are hidden. |
| `describe_table(table)` | Schema (with nested fields), row count, size, partitioning, and clustering. |
| `dry_run(sql)` | Bytes the query would scan, the tables it touches, and whether `run_query` would allow it, with the reasons if not. Nothing is billed. |
| `run_query(sql)` | Runs one statement after every guard. Returns rows as JSON, `truncated` when the row cap cut the result, and the bytes billed. |
## Guards on run_query
1. **Parse with sqlglot** (BigQuery dialect), not regex. SQL that doesn't parse is refused, and
so are scripts and multiple statements.
2. **Statement type.** Only queries run by default (`read_only: true`). Everything else is
blocked: `INSERT`, `UPDATE`, `DELETE`, `MERGE`, `CREATE_*`, `DROP_*`, `ALTER`, `TRUNCATE`,
`EXPORT`, `GRANT`, and so on. With `read_only: false`, only the types listed in
`allow_statements` run.
3. **Dataset and table allow and deny lists.** These check every table the SQL names, and every
table the dry run reports, so a view over a denied table is caught too. Deny wins over
allow. A wildcard table like `events_*` is refused while table deny rules exist, because it
could include a denied table.
4. **LIMIT.** A `SELECT` without a top-level `LIMIT` gets `LIMIT default_limit` appended to the
original text. The SQL is not regenerated, so it runs exactly as written plus the limit.
5. **Dry run first**, always. If the estimate exceeds `max_bytes_billed`, the query is refused
before any job exists.
6. **`maximum_bytes_billed` on the real job**, always, so BigQuery itself enforces the cap even
if the estimate was wrong.
7. **Row cap.** At most `max_rows` rows come back. `total_rows` and `truncated` say what was
left out.
8. **Labels.** Every job carries your `labels` plus `bq_guard=true` and
`bq_guard_tool=run_query|dry_run`, so you can find agent traffic in
`INFORMATION_SCHEMA.JOBS` and billing exports.
9. **Audit log.** One JSON line per request and decision.
`dry_run` runs checks 1, 3, and 4 before estimating. It reports statement-type and byte-limit
problems as `run_query_blockers` instead of refusing, because a dry run executes nothing.
## Install
```bash
pip install git+https://github.com/rk-chavali/bq-guard-mcp@v0.1.0
```
or with uv: `uv tool install git+https://github.com/rk-chavali/bq-guard-mcp@v0.1.0`. Python 3.11+.
**Authentication** uses Application Default Credentials. Locally, run
`gcloud auth application-default login`. On a server, use a service account (workload
identity, or `GOOGLE_APPLICATION_CREDENTIALS`). No credentials are needed to start the server.
They are loaded on the first tool call, and a clear error names the fix if they are missing.
## Connect it to your agent
### Claude Code
```bash
claude mcp add --transport stdio --scope project bq-guard -- bq-guard-mcp --policy bq-guard.yaml
```
or commit a `.mcp.json` at the project root:
```json
{
"mcpServers": {
"bq-guard": {
"command": "bq-guard-mcp",
"args": ["--policy", "bq-guard.yaml"]
}
}
}
```
### Cursor
`.cursor/mcp.json` in the project, or `~/.cursor/mcp.json` for every project:
```json
{
"mcpServers": {
"bq-guard": {
"command": "bq-guard-mcp",
"args": ["--policy", "/absolute/path/to/bq-guard.yaml"]
}
}
}
```
### VS Code
`.vscode/mcp.json`:
```json
{
"servers": {
"bq-guard": {
"type": "stdio",
"command": "bq-guard-mcp",
"args": ["--policy", "${workspaceFolder}/bq-guard.yaml"]
}
}
}
```
### Docker
```bash
docker build -t bq-guard-mcp .
docker run -i --rm \
-v "$HOME/.config/gcloud:/home/app/.config/gcloud:ro" \
-v "$PWD/bq-guard.yaml:/app/bq-guard.yaml:ro" \
bq-guard-mcp
```
The image runs as a non-root user, reads `/app/bq-guard.yaml`, and writes the audit log to
`/tmp`. Mount a volume and set `BQ_GUARD_AUDIT_LOG` to keep the log. The same snippets are in
[`examples/clients/`](examples/clients).
## Policy reference
The policy is found in this order: `--policy PATH`, `$BQ_GUARD_POLICY`, `./bq-guard.yaml`. If
none exists, the built-in defaults apply: read-only, a 1 GB byte cap, `LIMIT 1000`, and 1000
rows. Unknown keys and invalid values stop the server at startup with a clear message.
```yaml
project: acme-analytics # default: the ADC project
location: US
max_bytes_billed: 5000000000 # per query; dry run must be under it and the job is capped at it
read_only: true # only SELECT (default)
allow_statements: [] # with read_only: false, e.g. [INSERT, MERGE, "CREATE_*"]
datasets: # patterns: "dataset" or "project.dataset"
allow: ["analytics", "marts"]
deny: ["*_pii", "raw_*"]
tables: # patterns: "dataset.table" or "project.dataset.table"
deny: ["*.users", "*.*_secrets"]
enforce_limit: true
default_limit: 1000
max_rows: 500
timeout_seconds: 120
labels: # lowercase keys and values, BigQuery label rules
team: data-platform
audit_log: bq-guard-audit.jsonl # relative to the policy file; null disables; $BQ_GUARD_AUDIT_LOG overrides
audit_sql: hash # hash (default) or full
```
Patterns are shell-style globs (`*`, `?`) and are case-sensitive, like BigQuery names. An empty
`allow` list allows everything that isn't denied. The full example is
[`examples/bq-guard.yaml`](examples/bq-guard.yaml).
## Audit log
```json
{"ts": "2026-09-23T14:02:11+00:00", "tool": "run_query", "decision": "blocked", "sql_sha256": "5f1c...", "reason": "DELETE statements are blocked: the server is in read-only mode"}
{"ts": "2026-09-23T14:02:40+00:00", "tool": "run_query", "decision": "allowed", "sql_sha256": "a93e...", "statement_type": "SELECT", "bytes_estimated": 52428800, "bytes_billed": 52428800, "rows_returned": 500, "job_id": "job_ab12", "limit_added": true}
```
SQL is stored as a SHA-256 hash by default, because queries can contain customer data in
literals. Set `audit_sql: full` to keep the text. A failure to write the log is reported on
stderr and never blocks a request.
## Security notes
- **BigQuery IAM is the real boundary.** Give the server's identity only
`roles/bigquery.jobUser` on the billing project and `roles/bigquery.dataViewer` on the
datasets the agent should read. Grant write roles only if you allow writes in the policy.
The policy is defense in depth, and it produces refusals the agent understands. It is not a
substitute for IAM.
- The server runs over stdio and opens no network port.
- Tool errors report BigQuery's message but never credentials. Query results go to the agent,
and so to the model provider, so keep sensitive datasets out of the allow list.
## Limitations
- Table checks see the tables in the SQL and in the dry run's `referenced_tables`. Access that
neither shows, such as data read inside a remote function, is only limited by IAM.
- `LIMIT` is added only at the top level of a `SELECT`. It limits rows returned, not bytes
scanned, which is what `max_bytes_billed` is for.
- Multi-statement scripts, `DECLARE`, and procedural SQL are refused rather than analyzed.
- Region-level `INFORMATION_SCHEMA` views (`region-us.INFORMATION_SCHEMA.JOBS`) are treated as a
table in a dataset named after the region. Deny them explicitly if you need to.
## Development
```bash
uv sync
uv run ruff check . && uv run mypy && uv run pytest
```
The tests use a fake BigQuery backend and an in-memory MCP client. They need no GCP
credentials and make no network calls.
## License
MIT
TDQS
Scored across 5 tools
Each tool targets a distinct action: listing datasets, listing tables, describing a table, dry-running a query, and running a query. There is no overlap or ambiguity between them.
All tool names follow a consistent verb_noun snake_case pattern (list_datasets, list_tables, describe_table, dry_run, run_query). The naming is predictable and uniform.
Five tools is well-scoped for a BigQuery guard server, covering metadata exploration and query execution without excess or redundancy. Each tool earns its place.
The surface covers the core workflow: discover datasets, browse tables, inspect schema, estimate cost, and run queries. Minor gaps like dataset-level metadata or query cancellation are not essential for the stated purpose.