# The Polite Catastrophe of 'Read-Only': Connecting Coding Agents to a 300-Table Legacy SQL Database

> Source: <https://dev.to/tomzafred/the-polite-catastrophe-of-read-only-connecting-coding-agents-to-a-300-table-legacy-sql-database-f6a>
> Published: 2026-11-15 01:09:21+00:00

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.

It 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`.

Within four minutes, three separate operational incidents unfold in stately, unblemished silence:

`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.
First-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.

Community 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.

Mature institutional databases do not have twelve tidy tables. They resemble geological cliff faces.

Alongside `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.

Handing an agent a flat `list_tables()` array of `312` cryptic names without row-cardinality telemetry triggers two expensive failures:

`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.
Our 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.

```
# [ZOOM_1_SUBGRAPH: domain=INV_Custody | active_tables=6 | skipped_zero_row_husks=45]
dbo.tbl_INV_Custody_Entity_001 [rows=1250]   :: Entity_001Id(PK:INT), ExternalCode(IX:VARCHAR(50)), VolumeUnit(NVARCHAR(32)), CreatedUtc(IX:DATETIME2(3))
dbo.tbl_INV_Custody_Entity_010 [rows=336041] :: Entity_010Id(PK:INT), ExternalCode(IX:VARCHAR(50)), ParentRefId(FK->Entity_009:IX:INT), VolumeUnit(NVARCHAR(32))
```

In `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.

`CONVERT_IMPLICIT` Diplomatic Incident
Suppose the agent now writes a clean, parameterised lookup against an indexed `VARCHAR(50)` barcode column:

```
SELECT TOP (25) ExternalCode, VolumeUnit, CreatedUtc
FROM dbo.tbl_INV_Custody_Entity_001
WHERE ExternalCode = @code;
```

Every security reviewer applauds the parameter binding. Every fan in the server rack immediately spins to takeoff thrust.

JavaScript (`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:

```
|--Index Scan(OBJECT:([dbo].[tbl_INV_Custody_Entity_001].[IX_ExternalCode]),
     WHERE:(CONVERT_IMPLICIT(nvarchar(50), [ExternalCode], 0) = [@code]))
```

Rather 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.

Holding 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:

``` js
function coerceBoundParameters(boundParams, columnSchemaMap) {
  const coerced = {};
  for (const [name, spec] of Object.entries(boundParams || {})) {
    const col = columnSchemaMap[spec.column];
    const val = String(spec.value ?? '');
    if (!col || val.length > col.maxLen) throw new Error(`PARAM_BOUNDS_BLOCKED: ${name}`);
    if (!col.isUnicode && /[^\x00-\x7F]/.test(val)) {
      throw new Error(`NON_ASCII_ON_VARCHAR_INDEX_BLOCKED: ${name}`);
    }
    coerced[name] = { sqlType: `${col.isUnicode ? 'NVARCHAR' : 'VARCHAR'}(${col.maxLen})`, value: val };
  }
  return coerced;
}
```

Index seeks stay `0.2ms` index seeks, Unicode columns retain full UTF-16 fidelity, and oversized payloads fail before touching the socket.

`db_datareader` Is a Polite Museum Guard
Treating `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.

Even with hardware `DENY` on `INSERT`, `UPDATE`, `DELETE`, and `EXEC`, a valid `SELECT` can still destabilise production via four side channels:

`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.
**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.

``` js
const BLOCKED_VECTORS = [
  [/;/,                                                         'MULTI_STATEMENT_BATCH_BLOCKED'],
  [/^(?!SELECT\b|WITH\b)/i,                                     'NON_SELECT_ROOT_BLOCKED'],
  [/\bINTO\s+[#@A-Za-z_[\]]/i,                                  'SELECT_INTO_MUTATION_BLOCKED'],
  [/\b(?:OPENROWSET|OPENDATASOURCE|OPENQUERY|xp_dirtree)\b/i,   'EXTERNAL_ROWSET_SSRF_BLOCKED'],
  [/\bWAITFOR\s+(?:DELAY|TIME)\b/i,                             'WAITFOR_POOL_EXHAUSTION_BLOCKED'],
  [/\b(?:OBJECT_DEFINITION|sys\.sql_modules|sp_helptext)\b/i,   'PROC_SECRET_SCRAPE_BLOCKED'],
  [/\bWITH\s*\(\s*(?:TABLOCKX|UPDLOCK|XLOCK|HOLDLOCK|NOLOCK)\b/i,'UNSAFE_LOCK_OR_DIRTY_READ_BLOCKED']
];

function gateReadOnlyQuery(sqlText, params, schemaMap) {
  const clean = sqlText.replace(/--[^\n]*|\/\*[\s\S]*?\*\//g, ' ').replace(/N?'(?:''|[^'])*'/g, "''").trim().replace(/;\s*$/, '');
  for (const [pattern, code] of BLOCKED_VECTORS) {
    if (pattern.test(clean)) return { allowed: false, code };
  }
  if (!/\b(?:TOP\s*\(?\s*\d+|FETCH\s+NEXT\s+\d+\s+ROWS)\b/i.test(clean)) {
    return { allowed: false, code: 'MISSING_ROW_CAP_BLOCKED' };
  }
  return {
    allowed: true,
    preamble: 'SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SET LOCK_TIMEOUT 2000; SET DEADLOCK_PRIORITY LOW;',
    bindings: coerceBoundParameters(params, schemaMap)
  };
}
```

Notice 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.

`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):

| Architectural Strategy | Turns | Transcript Payload ( `Bytes` /`Tokens` ) | Zero-Row Husks Exposed | Pre-Flight Gate ( `p50` /`p95` ) | Production Lock & Scan Safety | 
|---|---|---|---|---|---|
| **Raw `INFORMATION_SCHEMA` Dump** | `1` | `433,876 B` /`108,469 tok` | `45` (`100%` noise) | None | Vulnerable ( `CONVERT_IMPLICIT` +`TABLOCK S` ) | 
| **Naive Lazy `describe_table` Loop** | `12` | `346,060 B` /`86,515 tok`*(cumulative)* | `45` (`3` queried) | None | Vulnerable ( `OPENROWSET` , CTE,`WAITFOR` open) | 
| **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)** | 

Connecting 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.

`biobanking.co.uk`) alongside turnkey read-only SQL Server and PostgreSQL MCP gateways for enterprise engineering teams.`workbench-guard`)
If 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.
