{"slug": "the-polite-catastrophe-of-read-only-connecting-coding-agents-to-a-300-table-sql", "title": "The Polite Catastrophe of 'Read-Only': Connecting Coding Agents to a 300-Table Legacy SQL Database", "summary": "A developer built a Model Context Protocol (MCP) database gateway that compiles metadata from sys.dm_db_partition_stats and sys.foreign_key_columns into a three-tier hierarchy, after discovering that naive read-only MCP wrappers can cripple a 312-table legacy SQL Server monolith without modifying a byte. The write-up documents second-order failures: an INFORMATION_SCHEMA dump of 433,876 bytes (108,469 tokens), a driver type-precedence trap that pinned all eight cores at 100% by up-converting 20 million VARCHAR rows to NVARCHAR(4000), and read-only lock escalation that locked 40 lab technicians out of cryo-vial records. The gateway's Zoom 0/1/2 design quarantines zero-row husk tables, emits keys, foreign-key edges, row counts and column encodings for a connected subgraph in one turn, and cuts a lazy describe_table loop that accumulated 346,060 bytes (86,515 tokens) over 12 round-trips.", "body_md": "There is a particular, quiet melancholy in watching a sixty-line script bring a fifteen-year-old enterprise database to its knees without modifying a single byte.\n\nIt nearly always begins on a Tuesday morning. An enthusiastic developer downloads an open-source Model Context Protocol (MCP) database wrapper, connects it to a `312`-table SQL Server monolith whose original architects retired during the coalition government to press artisanal cider in Somerset, and reassures management that the experiment is entirely harmless. After all, the database login sits strictly inside `db_datareader`. Every incoming query passes a cheerful regular expression checking that the string begins with `SELECT`.\n\nWithin four minutes, three separate operational incidents unfold in stately, unblemished silence:\n\n`INFORMATION_SCHEMA`, ingesting `433,876 bytes` (`108,469 tokens`) of Data Definition Language - roughly equivalent to tipping the entire contents of Companies House onto a barrister's desk and asking whether Tuesday's milk bill has been settled.`0` through `7` pin themselves at `100%` utilisation because the Node.js driver bound a fourteen-character ASCII barcode as UTF-16 `NVARCHAR(4000)`, prompting SQL Server to up-convert twenty million indexed `VARCHAR(50)` rows at runtime with polite, unhurried malice.`5,000` shared page locks, triggers lock escalation to a table-wide freeze (`TABLOCK S`), and locks forty laboratory technicians out of recording `-80 deg C` cryo-vial movements. Nothing was mutated. Nobody could work.\nFirst-order advice on database MCP tooling - splitting schema dumps into lazy `list_tables()` and `describe_table()` calls, enforcing `SELECT TOP 100`, and revoking write privileges - is already documented across vendor blogs. Those measures prevent juvenile vandalism. What fells mature production estates are **second-order database mechanics**: archaeological husk tables, multi-turn discovery drag, driver type-precedence traps, and read-only lock escalation.\n\nCommunity tutorials routinely prescribe progressive lazy-loading: call `list_tables()` first, then `describe_table(name)` only on relevant entities. On a greenfield SaaS app with twelve tidy tables named `users` and `invoices`, this behaves admirably.\n\nMature institutional databases do not have twelve tidy tables. They resemble geological cliff faces.\n\nAlongside `267` active operational tables, a fifteen-year-old schema preserves `45` fossilised remnants of forgotten consultancy engagements: `dbo.tbl_Archive_Dave_DoNotTouch_2011`, `dbo.tbl_Specimen_Backup_PreMigration_2013`, and the ever-reassuring `dbo.tmp_Import_LegacyBoxes_FINAL_v2`. These tables possess immaculate primary keys, plausible column names, foreign-key constraints, and precisely zero live rows. Nobody drops them because nobody remembers whether an overnight reporting job from 2014 still inspects their metadata.\n\nHanding an agent a flat `list_tables()` array of `312` cryptic names without row-cardinality telemetry triggers two expensive failures:\n\n`tbl_Specimen_Backup_PreMigration_2013` because its name looks clearer than `tbl_SPEC_Aliquot_Entity_014`, writes a syntactically flawless join, receives `0 rows`, and burns four turns diagnosing an imaginary outage.` describe_table()` across eleven candidate tables emits `40,802 bytes` of JSON. Because every subsequent turn replays prior tool outputs across the context window, that polite lazy loop accumulates `346,060 bytes` (`86,515 tokens`)` 12` round-trips.\nOur gateway compiles metadata from `sys.dm_db_partition_stats` and `sys.foreign_key_columns` (or `pg_stat_user_tables` and `pg_constraint` on PostgreSQL) into a three-tier hierarchy: **Zoom 0** groups active tables by domain while quarantining zero-row husks out of sight; **Zoom 1** emits primary keys, foreign-key edges, index flags, row counts, and character encodings (`VARCHAR` vs `NVARCHAR`) for an entire connected subgraph in one turn; **Zoom 2** exposes view DDL only when explicitly authorised.\n\n```\n# [ZOOM_1_SUBGRAPH: domain=INV_Custody | active_tables=6 | skipped_zero_row_husks=45]\ndbo.tbl_INV_Custody_Entity_001 [rows=1250]   :: Entity_001Id(PK:INT), ExternalCode(IX:VARCHAR(50)), VolumeUnit(NVARCHAR(32)), CreatedUtc(IX:DATETIME2(3))\ndbo.tbl_INV_Custody_Entity_010 [rows=336041] :: Entity_010Id(PK:INT), ExternalCode(IX:VARCHAR(50)), ParentRefId(FK->Entity_009:IX:INT), VolumeUnit(NVARCHAR(32))\n```\n\nIn `1,987 bytes` (`497 tokens`), the agent receives the live join graph, spots multi-hundred-thousand-row tables before writing careless scans, and skips forty-five empty decoys entirely.\n\n`CONVERT_IMPLICIT` Diplomatic Incident\nSuppose the agent now writes a clean, parameterised lookup against an indexed `VARCHAR(50)` barcode column:\n\n```\nSELECT TOP (25) ExternalCode, VolumeUnit, CreatedUtc\nFROM dbo.tbl_INV_Custody_Entity_001\nWHERE ExternalCode = @code;\n```\n\nEvery security reviewer applauds the parameter binding. Every fan in the server rack immediately spins to takeoff thrust.\n\nJavaScript (`node-mssql` / `tedious`), Python (` pyodbc`), and most generic MCP wrappers bind string parameters as UTF-16 **`NVARCHAR(4000)`** whenever explicit column types are omitted. Under SQL Server's type-precedence rules, `VARCHAR` ranks below `NVARCHAR`. Forbidden from narrowing UTF-16 input down to 8-bit `Code Page 1252`, the optimiser rewrites the predicate across the index:\n\n```\n|--Index Scan(OBJECT:([dbo].[tbl_INV_Custody_Entity_001].[IX_ExternalCode]),\n     WHERE:(CONVERT_IMPLICIT(nvarchar(50), [ExternalCode], 0) = [@code]))\n```\n\nRather than narrowing one parameter once, SQL Server widens **every indexed row** at runtime, degrading a `0.2ms` `Index Seek` into a multi-million-row `Index Scan`. Conversely, forcing *all* strings to `VARCHAR` quietly vandalises UTF-16 scientific units (`NCHAR(181)` / `U+00B5` micro-litres or `-80 deg C`), breaking `ALCOA+` data integrity.\n\nHolding the Zoom-1 column dictionary in memory allows the gateway to inspect target column metadata before dispatch and coerce each parameter to the exact schema encoding and length ceiling:\n\n``` js\nfunction coerceBoundParameters(boundParams, columnSchemaMap) {\n  const coerced = {};\n  for (const [name, spec] of Object.entries(boundParams || {})) {\n    const col = columnSchemaMap[spec.column];\n    const val = String(spec.value ?? '');\n    if (!col || val.length > col.maxLen) throw new Error(`PARAM_BOUNDS_BLOCKED: ${name}`);\n    if (!col.isUnicode && /[^\\x00-\\x7F]/.test(val)) {\n      throw new Error(`NON_ASCII_ON_VARCHAR_INDEX_BLOCKED: ${name}`);\n    }\n    coerced[name] = { sqlType: `${col.isUnicode ? 'NVARCHAR' : 'VARCHAR'}(${col.maxLen})`, value: val };\n  }\n  return coerced;\n}\n```\n\nIndex seeks stay `0.2ms` index seeks, Unicode columns retain full UTF-16 fidelity, and oversized payloads fail before touching the socket.\n\n`db_datareader` Is a Polite Museum Guard\nTreating `db_datareader` (or `pg_read_all_data`) as a complete security boundary is rather like hiring a courteous museum attendant who forbids visitors from stealing the oil paintings, yet cheerfully permits them to wedge the fire exits shut on a bank holiday.\n\nEven with hardware `DENY` on `INSERT`, `UPDATE`, `DELETE`, and `EXEC`, a valid `SELECT` can still destabilise production via four side channels:\n\n`READ_COMMITTED_SNAPSHOT` (`RCSI`), a poorly selective `SELECT` acquiring `INSERT` and `UPDATE` commits.`OPENROWSET` and `OPENDATASOURCE` coerce SQL Server into outbound network calls and NTLM relay inside internal VLANs.`OPTION (MAXRECURSION 0)` passes naive `VIEW DEFINITION` allows `SELECT OBJECT_DEFINITION(object_id) FROM sys.procedures`, exposing legacy credentials foolishly hardcoded in 2016.\n**Architectural Invariant:** Pair row-versioned snapshot isolation (`READ_COMMITTED_SNAPSHOT ON`, or PostgreSQL `default_transaction_read_only = on`) with `SET LOCK_TIMEOUT 2000`, `SET DEADLOCK_PRIORITY LOW` so the agent is always sacrificed ahead of human operators, and pre-flight AST rejection of side-channel constructs.\n\n``` js\nconst BLOCKED_VECTORS = [\n  [/;/,                                                         'MULTI_STATEMENT_BATCH_BLOCKED'],\n  [/^(?!SELECT\\b|WITH\\b)/i,                                     'NON_SELECT_ROOT_BLOCKED'],\n  [/\\bINTO\\s+[#@A-Za-z_[\\]]/i,                                  'SELECT_INTO_MUTATION_BLOCKED'],\n  [/\\b(?:OPENROWSET|OPENDATASOURCE|OPENQUERY|xp_dirtree)\\b/i,   'EXTERNAL_ROWSET_SSRF_BLOCKED'],\n  [/\\bWAITFOR\\s+(?:DELAY|TIME)\\b/i,                             'WAITFOR_POOL_EXHAUSTION_BLOCKED'],\n  [/\\b(?:OBJECT_DEFINITION|sys\\.sql_modules|sp_helptext)\\b/i,   'PROC_SECRET_SCRAPE_BLOCKED'],\n  [/\\bWITH\\s*\\(\\s*(?:TABLOCKX|UPDLOCK|XLOCK|HOLDLOCK|NOLOCK)\\b/i,'UNSAFE_LOCK_OR_DIRTY_READ_BLOCKED']\n];\n\nfunction gateReadOnlyQuery(sqlText, params, schemaMap) {\n  const clean = sqlText.replace(/--[^\\n]*|\\/\\*[\\s\\S]*?\\*\\//g, ' ').replace(/N?'(?:''|[^'])*'/g, \"''\").trim().replace(/;\\s*$/, '');\n  for (const [pattern, code] of BLOCKED_VECTORS) {\n    if (pattern.test(clean)) return { allowed: false, code };\n  }\n  if (!/\\b(?:TOP\\s*\\(?\\s*\\d+|FETCH\\s+NEXT\\s+\\d+\\s+ROWS)\\b/i.test(clean)) {\n    return { allowed: false, code: 'MISSING_ROW_CAP_BLOCKED' };\n  }\n  return {\n    allowed: true,\n    preamble: 'SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SET LOCK_TIMEOUT 2000; SET DEADLOCK_PRIORITY LOW;',\n    bindings: coerceBoundParameters(params, schemaMap)\n  };\n}\n```\n\nNotice that `NOLOCK` is banned alongside `TABLOCKX`. Just as agents fabricate test passes without [cryptographic execution receipts](https://dev.to/tomzafred/all-42-tests-passed-they-didnt-why-coding-agents-lie-about-verification-and-how-to-force-32id), permitting dirty reads (`NOLOCK`) lets an agent ingest uncommitted numbers that subsequently roll back, polluting its reasoning context with phantom inventory.\n\n`312`-Table Schema & Evaluated deterministically on Node.js v24 across a `312`-table legacy schema (` 267` populated tables plus `45` zero-row husks) and `60,000` adversarial pre-flight inspections (`5,000` x `12` bypass and type-mismatch vectors):\n\n| Architectural Strategy | Turns | Transcript Payload ( `Bytes` /`Tokens` ) | Zero-Row Husks Exposed | Pre-Flight Gate ( `p50` /`p95` ) | Production Lock & Scan Safety | \n|---|---|---|---|---|---|\n| **Raw `INFORMATION_SCHEMA` Dump** | `1` | `433,876 B` /`108,469 tok` | `45` (`100%` noise) | None | Vulnerable ( `CONVERT_IMPLICIT` +`TABLOCK S` ) | \n| **Naive Lazy `describe_table` Loop** | `12` | `346,060 B` /`86,515 tok`*(cumulative)* | `45` (`3` queried) | None | Vulnerable ( `OPENROWSET` , CTE,`WAITFOR` open) | \n| **3-Zoom Subgraph + Typed AST Gateway** | **`1`** | **` 1,987 B` / `497 tok` (`-99.5%`)** | **` 0` (quarantined)** | **`0.0007ms` / `0.0011ms`** | **Enforced (` VARCHAR` coerced, `LOCK_TIMEOUT 2000`, `12/12` halted)** | \n\nConnecting coding agents to a decade-old relational estate requires treating the MCP server as a typed, isolation-aware firewall: quarantine zero-row schema archaeology at Zoom 0, coerce `VARCHAR` vs `NVARCHAR` bindings from column metadata, enforce `RCSI` with `DEADLOCK_PRIORITY LOW`, and authenticate remote gateways via OAuth 2.1 PKCE rather than static plaintext bearer keys.\n\n`biobanking.co.uk`) alongside turnkey read-only SQL Server and PostgreSQL MCP gateways for enterprise engineering teams.`workbench-guard`)\nIf your own production database still harbours a cherished `DoNotDelete_2011` fossil table or a memorable `NOLOCK` misadventure, the comments below are open for archaeological comparison.", "url": "https://wpnews.pro/news/the-polite-catastrophe-of-read-only-connecting-coding-agents-to-a-300-table-sql", "canonical_source": "https://dev.to/tomzafred/the-polite-catastrophe-of-read-only-connecting-coding-agents-to-a-300-table-legacy-sql-database-f6a", "published_at": "2026-11-15 01:09:21+00:00", "updated_at": "2026-11-15 01:21:45.714805+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "agent-protocols", "developer-tools", "mlops"], "entities": ["Model Context Protocol", "SQL Server", "PostgreSQL", "Node.js", "sys.dm_db_partition_stats", "sys.foreign_key_columns", "INFORMATION_SCHEMA"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/the-polite-catastrophe-of-read-only-connecting-coding-agents-to-a-300-table-sql", "markdown": "https://wpnews.pro/news/the-polite-catastrophe-of-read-only-connecting-coding-agents-to-a-300-table-sql.md", "text": "https://wpnews.pro/news/the-polite-catastrophe-of-read-only-connecting-coding-agents-to-a-300-table-sql.txt", "jsonld": "https://wpnews.pro/news/the-polite-catastrophe-of-read-only-connecting-coding-agents-to-a-300-table-sql.jsonld"}}