Why I Built cost-guard-mcp: Pre-Flight Cost Guardrails for AI Agents Talking to Data Warehouses A developer built cost-guard-mcp, a Model Context Protocol server that sits in front of BigQuery and Snowflake to give AI agents pre-flight cost and byte estimates before executing SQL. The tool exposes three functions — describe_engine_capabilities, estimate_query_cost, and run_query_bounded — and enforces an accuracy_tier field (PRECISE, UPPER_BOUND, or HEURISTIC) as a required, non-defaulted field on its Pydantic CostEstimate model, so no estimate can be returned without declaring its reliability. BigQuery estimates are labeled PRECISE because they come from the engine's server-side dry-run planner, while Snowflake estimates are capped at UPPER_BOUND since EXPLAIN USING JSON reports planner-expected bytes that runtime optimizations can reduce. Give an AI agent a warehouse MCP server and, sooner or later, it will write a query that scans a full multi-terabyte table and quietly runs up a bill in the hundreds of dollars because it forgot a partition filter, or it will SELECT from something it thought was small and get back four million rows straight into its own context window. Neither of these requires a malicious agent or a bad prompt. It just requires an agent doing what agents do: writing plausible SQL against a schema it partially understands, then running it. A human DBA would eyeball the query, guess the table size, maybe run EXPLAIN first out of habit. An agent given a generic "run this SQL" tool has no equivalent instinct, and as far as I can tell, no warehouse MCP server on the market tells the agent what a query will cost or how much data it will return before that query actually executes. That gap is the entire reason cost-guard-mcp exists. It is a Model Context Protocol server that sits in front of BigQuery and Snowflake and gives an agent three tools: describe engine capabilities engine to learn what an engine can and can't tell you, estimate query cost engine, sql, warehouse to get a cost and byte estimate before running anything, and run query bounded engine, sql, max bytes billed, max rows, max estimated cost usd to actually execute a query with those caps enforced. It is a genuinely small project — 15 Python files, 715 lines under src/cost guard mcp/ — and it is genuinely new. The first commit landed 2026-09-12 at 22:23 IST, the feature-complete v1 landed a few hours later at 02:27, v0.1.0 shipped that same day, and v0.1.1 — the hardening pass I'll get to below — shipped today, 2026-09-13. The whole history of this project fits inside about eighteen hours. I'm not going to pretend otherwise; there's no multi-month backstory here, just a focused tool built fast and then immediately hardened. The design decision I care about most in this codebase isn't the warehouse integration — it's that estimate query cost never returns a bare number. Every CostEstimate it produces carries an accuracy tier field, and that field is not a comment or a README promise, it's a required field on the Pydantic model in src/cost guard mcp/types.py with no default. You cannot construct a CostEstimate without deciding what tier it belongs to. AGENTS.md in the repo states the intent behind this directly: it's "enforced structurally by the Pydantic model, not by convention." That distinction matters more than it sounds. A convention is something a future contributor forgets. A required field with no default is something the type checker refuses to let you skip. There are three tiers: PRECISE , UPPER BOUND , and HEURISTIC . BigQuery is where PRECISE actually happens, and it happens because BigQuery's dry-run API is not a client-side guess — it's a real submission to BigQuery's own query planner. dry run in src/cost guard mcp/engines/bigquery.py builds a bigquery.QueryJobConfig dry run=True, use query cache=False , sends it through client.query , and reads back total bytes processed from the real API response. BigQuery validates and fully plans the query server-side without executing or billing it, so the byte figure that comes back is the same figure the real execution would have produced. That's what earns the PRECISE label. Snowflake gets a structurally different treatment, and the code is explicit about why. explain estimate runs EXPLAIN USING JSON {sql} , parses the returned plan, and reads plan 'GlobalStats' 'bytesAssigned' as its byte figure — but that number describes what the query planner expects to scan, not what actually gets scanned, and Snowflake's own documentation quoted directly in a code comment says runtime plan optimizations "can reduce the number of partitions and bytes scanned." Because of that, explain estimate doesn't have a downgrade table the way BigQuery does — it hardcodes AccuracyTier.UPPER BOUND on line 84, unconditionally, because there is no better tier available to fall from. BigQuery's dry-run and Snowflake's EXPLAIN are answering genuinely different questions: one is "what will this actually cost," the other is "what is the most this should cost." The place this stops being an abstract distinction and becomes something you can watch happen is inside BigQuery itself. BigQuery's dry-run response includes its own confidence signal — totalBytesProcessedAccuracy — and dry run reads it via query job. properties 'statistics' 'query' 'totalBytesProcessedAccuracy' , defaulting to 'UNKNOWN' if it's missing, then maps it through a fixed table: PRECISE stays PRECISE ; LOWER BOUND , UPPER BOUND , UNKNOWN , and anything else all fall through to AccuracyTier.UPPER BOUND via the dict's own default. A unit test, test dry run treats unrecognized accuracy value as upper bound , feeds it a value BigQuery has never returned before — 'SOME FUTURE VALUE' — and asserts the tool still downgrades safely rather than defaulting to PRECISE . The practical consequence: run the exact same SQL text against an ordinary settled table and you get PRECISE with an empty caveats list. Run that identical SQL against a table with a pending streaming buffer, or a wildcard, or a federated source, and the tool automatically flips to UPPER BOUND and attaches a caveat quoting BigQuery's own raw accuracy string verbatim — something like "BigQuery reported this estimate's own accuracy as 'LOWER BOUND', not PRECISE — treating it conservatively as UPPER BOUND." Nothing about the query changed. Only BigQuery's own confidence in its byte count changed, and the tool surfaces that shift instead of quietly reporting one undifferentiated number both times. The third tier, HEURISTIC , is reserved for Databricks, and I want to be precise about its status: it exists in the type system — types.py defines the enum value, and the agent-facing docstring in server.py mentions it — but there is no engines/databricks.py , no pricing module, nothing. describe engine capabilities 'databricks' raises ValueError , and a test asserts exactly that. I deferred Databricks rather than ship it, because a Databricks estimate would necessarily be a heuristic guess with no dry-run and no EXPLAIN-equivalent bound behind it, and shipping a guess dressed up as a real number is precisely the failure mode this whole project exists to prevent. Better to leave the third tier as a load-bearing placeholder in the schema than half-honest in the field. The server itself bakes the sequencing into its own tool docstrings, not just into documentation: describe engine capabilities 's docstring tells an agent to call it "before estimate query cost or run query bounded to understand how much to trust the accuracy tier on their responses for this engine," and estimate query cost 's docstring spells out what each tier means in the response itself. That's the actual contract an agent operates against — not marketing copy, code the agent reads. The second thread running through this project is fail-closed, least-privilege design, and it shows up as a pattern rather than a single feature. The clearest instance is in run query bounded . If a caller passes max estimated cost usd but the estimate that came back has estimated cost usd=None — which happens for a BigQuery Editions/capacity-billed project, since capacity billing has no fixed dollar-per-byte rate to convert from — the tool doesn't run the query with that cap silently unenforced. It refuses. The check in src/cost guard mcp/tools/run query bounded.py returns a normal BoundedQueryResult with status="refused" and reason=RefusalReason.COST CAP EXCEEDED , plus a plain-English hint explaining exactly why: it couldn't produce a dollar estimate, so it's refusing rather than running uncapped. This is a normal, successful MCP tool result, not an exception and not the MCP error channel — that's a deliberate choice recorded in the project's own ADR 4, so a caller can distinguish "your query is too expensive" from "your MCP client is broken" cleanly. Two other examples of the same instinct: Snowflake connections require an explicit SNOWFLAKE ROLE environment variable with no default, and config.py raises a ConfigError if it's unset, specifically to prevent ever falling back to ACCOUNTADMIN . And every function in both engine modules that touches snowflake.connector or the BigQuery client libraries is wrapped by a sanitize exceptions decorator, which regex-redacts five categories of secret material — passwords, private keys and PEM blocks, tokens/API keys, and user:password@host URIs — from any exception before it can reach a caller or a log line, because snowflake-connector-python has a documented history of leaking credentials into raw exception text. The part of this story I find most worth being honest about is that two of the security fixes weren't found by a later audit — they were found and closed the same day the vulnerable code shipped. Git history has the receipts: a commit titled fix security : validate Snowflake warehouse name before USE WAREHOUSE , because the warehouse parameter — caller-supplied, same trust level as the SQL itself — was being interpolated straight into USE WAREHOUSE {warehouse} . That statement is executed directly against the live Snowflake session via cur.execute , so an unvalidated warehouse name wasn't just a cosmetic risk: a caller could have appended a semicolon and a second statement after the intended identifier, turning a parameter meant to just pick a compute resource into a general SQL execution primitive scoped by whatever the connection's role could already reach. The second commit, fix bigquery : validate project ID before interpolating into API filter , whose own message says it was "flagged by automated security review after the previous commit introduced the query filter," closes a related but structurally different gap: the GCP project ID isn't actually reachable from an MCP tool parameter today, but the code treats the identifier as if it could be — validated on the theory that anything string-interpolated into an API filter expression deserves the same allowlist treatment as untrusted input, not just the fields a caller happens to be able to reach right now. Both are closed with allowlist regex validators — validate warehouse and validate project id — that fail loudly with a ValueError itself sanitized rather than let a malformed identifier reach a query-language context. Both incidents are written up together as ADR 6 in DECISIONS.md , which turns them into a standing rule for any engine this project adds in the future. One more mechanism worth naming because it's small and clever: row-bounded execution on both engines wraps the caller's SQL as SELECT FROM