Skip to main content
Glama
rk-chavali

bq-guard-mcp

by rk-chavali
README.md
# 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

A3.9/5.0

Scored across 5 tools

Disambiguation5/5

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.

Naming Consistency5/5

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.

Tool Count5/5

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.

Completeness4/5

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues