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-: 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.
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:
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.
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, 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.