{"slug": "your-database-mcp-is-lying-to-you", "title": "Your Database MCP Is Lying to You", "summary": "A developer discovered that database MCP agents can return zero rows for a query without error, making a wrong guess look identical to a correct query on an empty result set. The agent ran `SELECT count() FROM orders WHERE details ->> 'express_delivery' = 'true'` and reported zero orders, but the real answer was 2,317 because the actual key was `fulfillment.shippingV2.expressDelivery`. The developer proposes using recursive CTEs with `jsonb_each`, `jsonb_array_elements`, and `jsonb_typeof` to let the database infer the schema instead of shipping raw rows to the model.", "body_md": "Here's a conversation I've had more than once.\n\nMe:How many orders use express delivery?\n\nAgent:(runs `SELECT count() FROM orders WHERE details ->> 'express_delivery' = 'true'`)*\n\nAgent:None. No orders in the table use express delivery.\n\nThe real answer was 2,317.\n\nThe agent didn't hallucinate. It ran a real query against a real database and reported the real result. The query was just *wrong* — and this is the part that matters — nothing anywhere in the stack was capable of noticing.\n\nAsk your MCP server to describe the table and it tells you the truth, as far as it goes:\n\n```\n column_name | data_type\n-------------+-----------\n id          | uuid\n details     | jsonb\n```\n\n`jsonb`\n\n. That's the entire contract. That column could hold `{}`\n\nin every row or a nine-level fulfillment tree with 400 distinct paths, and `information_schema`\n\ngenuinely cannot tell you which — because as far as Postgres's type system is concerned, `jsonb`\n\nis one scalar type. The structure lives in the value, not the type. Nobody ever told the catalog.\n\nSo the model does the only thing available to it: pattern-matches a plausible key name out of the column name and whatever's floating around the conversation. It writes `express_delivery`\n\n. The data says:\n\n```\nfulfillment.shippingV2.expressDelivery\n```\n\nSnake case or camel case. Flat or nested. Singular or plural. Three independent coin flips, and it needed to win all three.\n\nIf a wrong guess threw an error, none of this would matter. The agent would see the error, adjust, try again. That loop works fine — it's most of what agents do all day.\n\nBut `->`\n\non a missing key returns `NULL`\n\n. `NULL = 'true'`\n\nevaluates to `NULL`\n\n. `NULL`\n\nfilters the row out. So a wrong guess returns **zero rows**, which is a pixel-perfect impostor of \"no data matches.\"\n\nThat's the whole failure. Not a bad model, not a bad prompt — a severed feedback loop. Every wrong guess looks identical to a correct query on an empty result set, so the model has no signal to self-correct. It reports its guess as a finding, with the serene confidence of a weather presenter. And then somebody puts the number in a deck.\n\nWorth sitting with: you have exactly the same problem. You just have Slack history and a colleague named Dave to compensate. The agent has neither. (Dave, in my experience, has also left the company.)\n\nThe obvious move is to hand the model some rows and let it work the shape out:\n\n```\nSELECT details FROM orders LIMIT 20;\n```\n\nThis is worse than it looks. Twenty 40KB documents is tens of thousands of tokens spent on a *bad* inference — the model sees whichever paths happened to occur in those particular twenty rows, with no frequency information, no type consistency check, and no concept of what it missed. Optional fields look mandatory. Rare fields don't exist at all. And you've burned half your context window getting there.\n\nYou're shipping the data to the reasoning. The data is four orders of magnitude bigger than the answer.\n\nMake the database do the aggregation and hand the model the conclusion.\n\n`jsonb`\n\nisn't text — it's a parsed binary structure, and Postgres can iterate it without re-parsing anything. Three primitives are all you need: `jsonb_each`\n\n, `jsonb_array_elements`\n\n, and `jsonb_typeof`\n\n. Wire them into a recursive CTE and the entire walk runs server-side:\n\n```\nWITH RECURSIVE walk AS (\n    SELECT s.rid, e.key AS path, e.value, 1 AS depth\n    FROM sample s, LATERAL jsonb_each(s.details) e\n    WHERE jsonb_typeof(s.details) = 'object'\n    UNION ALL\n    SELECT w.rid, w.path || child.suffix, child.value, w.depth + 1\n    FROM walk w\n    CROSS JOIN LATERAL (\n        SELECT '.' || e.key AS suffix, e.value\n        FROM jsonb_each(w.value) e WHERE jsonb_typeof(w.value) = 'object'\n      UNION ALL\n        SELECT '[]', el.value\n        FROM jsonb_array_elements(w.value) el WHERE jsonb_typeof(w.value) = 'array'\n    ) child\n    WHERE w.depth < 8\n)\nSELECT path,\n       array_agg(DISTINCT jsonb_typeof(value)) AS types,\n       count(DISTINCT rid)                     AS occurrences\nFROM walk GROUP BY path;\n```\n\nThat `CROSS JOIN LATERAL`\n\nin the recursive term isn't stylistic. SQL allows exactly one self-reference in a recursive term, and objects and arrays are two different descent rules — unioning them inside a lateral subquery is what makes the whole thing legal.\n\nWhat comes back is one row per distinct path instead of one row per document:\n\n```\n                  path                   |   types   | occurrences\n-----------------------------------------+-----------+-------------\n fulfillment.shippingV2.enabled          | {boolean} |         973\n fulfillment.shippingV2.expressDelivery  | {boolean} |           3\n lineItems[].sku                         | {string}  |         998\n status                                  | {string}  |        1000\n```\n\nFour lines. A few hundred tokens. The mystery from the top of this post solves itself on sight.\n\nThree things turn that from clever into practical:\n\n**Sample by page, not by row.** `TABLESAMPLE SYSTEM`\n\n— or `system_rows`\n\nif the contrib extension is installed — reads random *pages* instead of scanning, so cost scales with your sample rather than your table. `ORDER BY random()`\n\nis a full scan wearing a trench coat; it reads all 52 million rows to hand you a thousand.\n\n**Stop on convergence.** Profile in batches, count newly-discovered paths per batch, stop when a few consecutive batches turn up nothing new. Production JSON is emitted by a finite amount of application code, so the discovery curve is steep — most tables plateau by row 300–500.\n\n**Report your uncertainty.** Sampling *will* miss the key that appears in 3 rows out of 52 million, and the consumer needs to know that:\n\n```\n\"sampling\": {\n  \"method\": \"system_rows\",\n  \"rows_scanned\": 1500,\n  \"estimated_total_rows\": 52000000,\n  \"converged\": true,\n  \"missing_mass\": 0.004,\n  \"note\": \"frequency_pct is computed over the sample, not the whole table\"\n}\n```\n\n`missing_mass`\n\nthere is Good–Turing: singleton paths divided by documents sampled. `0.004`\n\nmeans roughly one document in 250 carries a path you haven't seen — comfortably fine to write queries against. `0.20`\n\nmeans the table is heterogeneous and your map isn't trustworthy yet. A profiler that reports \"complete\" after touching 0.003% of a table is worse than no profiler at all, because it launders a known unknown into an unknown unknown.\n\nExpose that as an MCP tool and the loop closes:\n\n```\nmcp.NewTool(\"profile_json_columns\",\n    mcp.WithDescription(\"Infer json/jsonb structure (key paths, types, frequency, examples); block-samples to convergence.\"),\n    mcp.WithString(\"schema\", mcp.Required()),\n    mcp.WithString(\"table\", mcp.Required()),\n    mcp.WithString(\"column\"),\n)\n```\n\nThe model calls it, gets the real path map, and writes `details #>> '{fulfillment,shippingV2,expressDelivery}'`\n\non the first attempt instead of the fourth. Database does the aggregation, model gets the conclusion, context window survives.\n\nProfile a column and you get one for free. When a single path comes back as `\"types\": [\"number\", \"string\"]`\n\n, you have both `{\"amount\": 1999}`\n\nand `{\"amount\": \"19.99\"}`\n\nsitting in production — two writers, two conventions, one storing integer cents and one storing dollar strings. A latent cast error stacked on a latent 100x pricing bug, discovered by asking an entirely unrelated question.\n\nFrequency reads as a design review too. A path at 100% isn't optional data; it's a column in witness protection, and `ALTER TABLE ... GENERATED ALWAYS AS`\n\nwill let it out. A path under 1% is usually somebody's abandoned experiment.\n\nYour agent isn't lying on purpose. It's answering from an empty catalog, and `jsonb`\n\nfails silently, so nothing in the loop can distinguish a wrong query from no data. Fix the catalog problem and the lying stops:\n\n`jsonb_each`\n\n/ `jsonb_array_elements`\n\n. Gigabytes stay where they are; kilobytes come back.`TABLESAMPLE SYSTEM`\n\nmakes cost a function of the sample, not the table.The same argument holds with no AI anywhere in the picture, incidentally. This is just what `\\d+`\n\nfor `jsonb`\n\nwould look like if Postgres shipped one.\n\n**Want the full treatment?** I wrote a considerably longer version that goes into the storage layer, why the query planner is equally blind (`contsel`\n\nreturns a hardcoded 0.001 for *every* containment query, regardless of your data), the four non-obvious details hiding in that recursive CTE, why `ctid`\n\nisn't unique across partitions and will silently corrupt any dedupe built on it, and the statistics behind convergence:\n\nImplementation and MCP wiring: [github.com/rasikraj01/psql-json-profiling-mcp](https://github.com/rasikraj01/psql-json-profiling-mcp)\n\nEither way: go find out what's actually in that column. I promise it's weirder than you think.", "url": "https://wpnews.pro/news/your-database-mcp-is-lying-to-you", "canonical_source": "https://dev.to/rasikraj01/your-database-mcp-is-lying-to-you-2dkk", "published_at": "2026-07-25 20:26:41+00:00", "updated_at": "2026-07-25 21:01:48.757312+00:00", "lang": "en", "topics": ["artificial-intelligence", "large-language-models", "ai-agents", "developer-tools"], "entities": ["Postgres"], "alternates": {"html": "https://wpnews.pro/news/your-database-mcp-is-lying-to-you", "markdown": "https://wpnews.pro/news/your-database-mcp-is-lying-to-you.md", "text": "https://wpnews.pro/news/your-database-mcp-is-lying-to-you.txt", "jsonld": "https://wpnews.pro/news/your-database-mcp-is-lying-to-you.jsonld"}}