Model Context Protocol (MCP)

Connecting Agents to Databases (PostgreSQL & Vector Search)


An analytics agent goes live on a Monday. By Tuesday afternoon the on-call engineer is looking at FATAL: sorry, too many clients already in the Postgres log, and the web application — which has nothing to do with the agent — is down.

The agent's MCP server opens a fresh connection at the top of every tool call and closes it at the bottom. That looks harmless in a unit test. In production, a single research question triggers around forty tool calls, three analysts are using it, and the retry logic opens a new connection for each attempt. Postgres max_connections is the default 100. Each backend process costs roughly 5–10 MB of server memory before it does any work, and each connection costs 30–50 ms to establish once you count the TCP handshake, the TLS negotiation and authentication.

Price the waste. Forty calls at 40 ms of setup is 1.6 seconds per question spent on connection handshakes alone, against roughly 0.2 ms to check a connection out of a pool — a factor of two hundred. And that is the benign failure. The real one is that the agent's churn starved the connection slots the application needed.

A database MCP server is not "a wrapper around a SQL client". It is a piece of production infrastructure sitting between a non-deterministic caller and your most valuable data, and it has to be built like one.

One query, from pool to bounded resultCheck outa pooledconnectionBind argumentsas parametersRun with astatementtimeoutCap rows andbytes returnedRelease theconnection, alwaysAn unpooled agent opens a connection per call and takes the web app down with it.
Parameterisation stops injection; only the row and byte caps stop one honest query from eating the context window.

Where the server sits

Text
   agent host                 database MCP server            PostgreSQL  +-----------+            +---------------------+        +--------------+  |  model    |    MCP     |  pool               |  pool  | read replica |  |  loop     |<---------->|  validate           |<------>| + pgvector   |  |           |  JSON-RPC  |  bound + summarise  |        |              |  +-----------+            +---------------------+        +--------------+        ^                            ^        |                            |   sees summaries,             holds credentials,   never raw rows              enforces every limit

Everything the model is allowed to know about the database passes through this process, which makes it the only sensible place to enforce limits. The agent cannot be trusted to cap its own row counts; it is a language model, and "return only what you need" is a suggestion it will sometimes ignore.

Connection pooling

A pool holds a small set of already-authenticated connections and lends them out. The mechanics matter because the sizing rule is counter-intuitive: a bigger pool is usually slower. Beyond the number of cores and spindles the database can actually work in parallel, extra connections add context switching and lock contention, not throughput.

Python
import asyncpgfrom contextlib import asynccontextmanagerPOOL: asyncpg.Pool | None = Noneasync def init_pool() -> None:    global POOL    POOL = await asyncpg.create_pool(        dsn="postgresql://agent_ro@db.internal:5432/analytics",        min_size=2,             # keep a couple warm so the first call is fast        max_size=10,            # hard ceiling on this server's footprint        max_inactive_connection_lifetime=300,   # recycle idle connections        command_timeout=30,     # client-side ceiling on any single statement        server_settings={            "application_name": "mcp-analytics",   # shows up in pg_stat_activity            "statement_timeout": "10000",          # 10 s, enforced by the server            "idle_in_transaction_session_timeout": "15000",        },    )@asynccontextmanagerasync def acquire():    async with POOL.acquire() as conn:        yield conn
SettingSensible valueWhat goes wrong otherwise
min_size20 means the first query of the day pays full handshake cost
max_size5–10 per server instanceToo high starves other applications; too low queues requests
statement_timeout5–30 sOne accidental cross join pins a CPU until someone notices
idle_in_transaction_session_timeout15 sAn abandoned transaction holds locks and blocks VACUUM indefinitely
application_nameName your serverYou cannot tell which client is causing load in pg_stat_activity

Sizing with numbers: if the median query takes 20 ms, one connection sustains 50 queries per second. A pool of 10 sustains 500 per second — vastly more than any interactive agent generates. If you find yourself wanting max_size=50, the queries are slow, not the pool small.

Pool size is a limit on how much damage a runaway agent can do to your database, not a performance dial. Choose it as a budget.

Executing SQL safely

The injection problem, concretely

Suppose a tool accepts a customer email and the handler builds a query by formatting a string:

Python
sql = f"select id, total_cents from orders where email = '{email}'"

Now the model — persuaded by a poisoned document it read earlier, or simply confused — passes:

Text
email = "x' union select id, 0 from api_keys --"

The database receives one statement that reads a table nobody meant to expose. Parameterisation removes this completely:

Python
rows = await conn.fetch("select id, total_cents from orders where email = $1", email)

The driver sends the query text and the value over separate protocol fields. The value is never parsed as SQL, so the entire string — quotes, union, comment marker and all — is treated as one improbable email address that matches zero rows.

What parameterisation does not protect against

Three gaps survive, and they are where careful teams still get hurt.

  1. Identifiers cannot be parameters. Table names, column names and sort directions are part of the query's structure. order by $1 does not do what you want; the database treats it as a constant, silently sorting by nothing.
  2. Cost is not injection but is still an attack. select * from events a, events b is perfectly valid, parameter-free SQL that produces a cross join of a billion rows.
  3. Reading everything is a leak even when it is authorised. A syntactically innocent select * from users exfiltrates a table into a model's context.
Python
import reimport sqlglotfrom sqlglot import expIDENT = re.compile(r"^[a-z_][a-z0-9_]{0,62}$")ALLOWED_TABLES = {"orders", "customers", "order_items", "products"}def validate_select(sql: str) -> str:    """Parse the statement and refuse anything that is not a bounded read."""    try:        parsed = sqlglot.parse(sql, read="postgres")    except Exception as e:        raise QueryRejected(f"could not parse SQL: {e}")    if len(parsed) != 1:        raise QueryRejected("exactly one statement is allowed")    stmt = parsed[0]    if not isinstance(stmt, exp.Select):        raise QueryRejected(f"only SELECT is allowed, got {type(stmt).__name__}")    for table in stmt.find_all(exp.Table):        if table.name not in ALLOWED_TABLES:            raise QueryRejected(f"table not allowed: {table.name}")    if stmt.find(exp.Star) and not stmt.args.get("limit"):        raise QueryRejected("SELECT * requires an explicit LIMIT")    return sql

Parsing beats regexes here, and the reason is worth internalising. A blocklist of the words drop, delete and update is defeated by a column literally named update_count, by comments (DR/**/OP), and by case tricks. A parser understands the statement's structure, so "is this a SELECT?" is answered by the shape of the tree rather than by string matching.

Add two more gates below the parser. Run the server's connection as a role with SELECT and nothing else, so that a parser bypass still cannot write. And gate expensive plans with EXPLAIN before executing:

Python
import jsonMAX_PLAN_COST = 500_000.0async def guard_cost(conn, sql: str, args: list) -> None:    # asyncpg returns a json column as a str unless you register a codec.    plan = json.loads(await conn.fetchval(f"explain (format json) {sql}", *args))    cost = plan[0]["Plan"]["Total Cost"]    if cost > MAX_PLAN_COST:        raise QueryRejected(            f"estimated cost {cost:,.0f} exceeds the limit of {MAX_PLAN_COST:,.0f}. "            f"Add a WHERE clause on an indexed column, or narrow the date range.")

Note the wording of that rejection. It tells the model what to change. "Query too expensive" produces an identical retry; naming the fix produces a better query on the next turn.

Bounding what comes back

Even a legitimate query can destroy a conversation. A result of 4,300 rows across 12 columns serialises to roughly 90,000 tokens — nearly half a 200k context window, for data the model cannot reason over anyway. Cap in three places:

Python
MAX_ROWS, MAX_CHARS = 200, 40_000async def run_read(conn, sql: str, args: list) -> str:    await guard_cost(conn, sql, args)    rows = await conn.fetch(f"select * from ({sql}) sub limit {MAX_ROWS + 1}", *args)    truncated = len(rows) > MAX_ROWS    rows = rows[:MAX_ROWS]    header = " | ".join(rows[0].keys()) if rows else "(no columns)"    body = "\n".join(" | ".join(str(v)[:120] for v in r.values()) for r in rows)    out = f"{header}\n{body}"    if len(out) > MAX_CHARS:        out = out[:MAX_CHARS] + "\n... output truncated at 40,000 characters ..."    if truncated:        out += (f"\n\nShowing the first {MAX_ROWS} rows; more matched. "                f"Add a WHERE clause or use an aggregate to narrow the result.")    return out

Fetching MAX_ROWS + 1 is the small trick that lets you say "there were more" without counting them. Delimited text rather than JSON is worth about a 40% token saving on wide results, because JSON repeats every key on every row.

Schema introspection

An agent writing SQL against a schema it has not seen will invent column names. Exposing the schema is the single highest-value thing a database server does, and it belongs as a resource — it is a read with no side effects, and the host may want to load it into context proactively.

Python
@mcp.resource("db://analytics/schema/{table}")async def table_schema(table: str) -> str:    if not IDENT.match(table) or table not in ALLOWED_TABLES:        raise ValueError(f"unknown table: {table}")    async with acquire() as conn:        cols = await conn.fetch("""            select c.column_name, c.data_type, c.is_nullable, c.column_default,                   col_description(('public.' || c.table_name)::regclass,                                   c.ordinal_position) as comment              from information_schema.columns c             where c.table_schema = 'public' and c.table_name = $1             order by c.ordinal_position""", table)        fks = await conn.fetch("""            select kcu.column_name, ccu.table_name as ref_table,                   ccu.column_name as ref_column              from information_schema.table_constraints tc              join information_schema.key_column_usage kcu                on kcu.constraint_name = tc.constraint_name              join information_schema.constraint_column_usage ccu                on ccu.constraint_name = tc.constraint_name             where tc.table_name = $1 and tc.constraint_type = 'FOREIGN KEY'""", table)        n = await conn.fetchval(            "select reltuples::bigint from pg_class where relname = $1", table)    lines = [f"TABLE public.{table}  (~{n:,} rows)"]    for c in cols:        null = "" if c["is_nullable"] == "YES" else " NOT NULL"        note = f"  -- {c['comment']}" if c["comment"] else ""        lines.append(f"  {c['column_name']}: {c['data_type']}{null}{note}")    for f in fks:        lines.append(f"  FK {f['column_name']} -> {f['ref_table']}.{f['ref_column']}")    return "\n".join(lines)

Include the column comments and the foreign keys. A model that can see FK customer_id -> customers.id writes the join correctly on the first attempt; one that cannot will guess at customers.customer_id and fail. The approximate row count from pg_class.reltuples costs nothing and tells the model whether a full scan is reasonable.

Semantic search with pgvector

Exact matching fails on the queries agents actually receive. "Why do customers cancel?" does not contain the word "churn", and where reason ilike '%cancel%' misses every ticket that said "not renewing". Vector search compares meaning by embedding text as a point in high-dimensional space and finding nearby points.

SQL
create extension if not exists vector;create table doc_chunks (  id          bigserial primary key,  doc_id      text        not null,  chunk_index int         not null,  content     text        not null,  metadata    jsonb       not null default '{}',  embedding   vector(1536) not null,  created_at  timestamptz not null default now());-- Approximate-nearest-neighbour index. Cosine distance must match the-- operator used at query time, or the index will simply be ignored.create index doc_chunks_hnsw    on doc_chunks using hnsw (embedding vector_cosine_ops)    with (m = 16, ef_construction = 64);create index doc_chunks_meta on doc_chunks using gin (metadata);

Why the index is not optional: a 1536-dimension float32 vector occupies 1536 × 4 = 6,144 bytes. Two million chunks is 12.3 GB that an exact scan must read and compare on every query — seconds, not milliseconds. HNSW builds a navigable graph and answers in single-digit milliseconds at roughly 95% recall. You trade a small chance of missing a result for a thousandfold speed-up, and for retrieval feeding a model that trade is nearly always correct.

Python
async def semantic_search(query: str, k: int = 5, doc_type: str | None = None) -> str:    vec = await embed(query)                    # 1536 floats from your embedding model    literal = "[" + ",".join(f"{x:.6f}" for x in vec) + "]"    async with acquire() as conn, conn.transaction():   # SET LOCAL needs a transaction        await conn.execute("set local hnsw.ef_search = 64")   # recall/latency dial        rows = await conn.fetch("""            select doc_id, chunk_index, content, metadata,                   1 - (embedding <=> $1::vector) as similarity              from doc_chunks             where ($3::text is null or metadata->>'type' = $3)             order by embedding <=> $1::vector             limit $2""", literal, k, doc_type)    return "\n\n".join(        f"[{r['doc_id']}#{r['chunk_index']}  sim={r['similarity']:.3f}]\n"        f"{r['content'][:1200]}" for r in rows)

Three operators exist and mixing them up silently ruins results: <=> is cosine distance, <-> is Euclidean, <#> is negative inner product. Cosine distance runs from 0 (identical direction) to 2 (opposite), so similarity is 1 - distance. The order by must use the same operator class the index was built with, or Postgres quietly falls back to a sequential scan and your fast search becomes a slow one with no error to tell you.

Hybrid search, and why you want it

Vector search is weak exactly where keyword search is strong: exact identifiers. A query for ord_8812 has no useful semantics. Run both and fuse the rankings with Reciprocal Rank Fusion, which scores each document as the sum of 1 / (60 + rank) across the lists it appears in.

Concretely, with two candidate chunks:

ChunkVector rankKeyword rankRRF scoreFinal
A181/61 + 1/68 = 0.01639 + 0.01471 = 0.031102nd
B321/63 + 1/62 = 0.01587 + 0.01613 = 0.032001st

Chunk A wins the vector list outright, but B is respectable in both and takes the fused top spot. That is the behaviour you want: agreement across two independent signals beats a single strong opinion. The constant 60 damps the influence of rank-1 positions, which is what stops one list from dominating.

Resources or tools: which is which

CapabilityExpose asWhy
Table schemaResource template db://.../schema/{table}Side-effect free, host may preload it
List of tablesResourceSmall, stable, useful as context
Metrics glossaryResourceStatic document the model should read
Run a read-only queryToolArbitrary input, needs validation and cost gating
Semantic searchToolTakes a query the model composes
Insert or update rowsTool, with destructiveHintSide effects, needs explicit consent

The rule of thumb: if the host could sensibly load it before the user says anything, it is a resource. If it needs an argument the model invents, it is a tool.

A model can compose any query it can imagine, so every limit that protects the database has to live in the server. Nothing above it is bounded.

Transactions and writes

The tempting design is begin_transaction, execute, commit as three separate tools. Do not build it. A model that opens a transaction and then decides to ask a clarifying question leaves a connection idle in transaction, holding locks, blocking VACUUM, and eventually blocking DDL for the whole table. There is no timeout on the model's attention span.

Expose atomic units instead — one tool, one transaction, committed or rolled back before the call returns:

Python
@mcp.tool()async def record_refund(order_id: str, amount_cents: int, reason: str,                        idempotency_key: str) -> str:    """Record a refund against an order. Atomic. Safe to retry with the same key."""    if not (0 < amount_cents <= 1_000_00):        return f"amount_cents must be between 1 and 100000, got {amount_cents}"    async with acquire() as conn:        async with conn.transaction():            prior = await conn.fetchval(                "select refund_id from refunds where idempotency_key = $1",                idempotency_key)            if prior:                return f"Already recorded as {prior} (idempotent replay)."            order = await conn.fetchrow(                "select total_cents, refunded_cents from orders "                "where id = $1 for update", order_id)            if order is None:                return f"No such order: {order_id}"            remaining = order["total_cents"] - order["refunded_cents"]            if amount_cents > remaining:                return (f"Refund of {amount_cents} exceeds the remaining "                        f"refundable balance of {remaining} cents on {order_id}.")            refund_id = await conn.fetchval(                "insert into refunds (order_id, amount_cents, reason, idempotency_key) "                "values ($1,$2,$3,$4) returning refund_id",                order_id, amount_cents, reason, idempotency_key)            await conn.execute(                "update orders set refunded_cents = refunded_cents + $1 where id = $2",                amount_cents, order_id)    return f"Recorded refund {refund_id} of {amount_cents} cents against {order_id}."

The idempotency key is what makes retries safe. Agents retry — after a timeout, after a truncated response, after the model forgets it already called the tool. Without a key, a network blip during a refund produces two refunds. With one, the second call returns the first result and changes nothing. The for update lock closes the concurrent-double-refund race that the key alone would not.

Notice too that every failure returns a sentence, not an exception. "Refund of 5000 exceeds the remaining refundable balance of 1200 cents on ord_8812" lets the agent explain the situation to the user. A stack trace does not.

The moment a transaction spans two tool calls, its lifetime is decided by a language model — and a language model has no timeout on its attention.

What this means when you build one

The design pressure on a database MCP server all points the same way: assume the caller is well-intentioned, occasionally confused, and never bound by your assumptions.

That produces a short list of decisions to make before writing a handler. Pick the database role first — a role with only SELECT on four tables makes an entire class of bugs unreachable, and it takes ten minutes to create. Put a number on every unbounded thing: rows, characters, plan cost, statement time, pool size, transaction lifetime. Each of those is a place where an agent will otherwise ask for infinity. Return prose, not exceptions, because every error message is really a prompt telling the model what to do next. And keep transactions inside a single tool call, because the moment a transaction spans two calls, its lifetime is controlled by a language model.

The team from the opening changed four things: a pool with max_size=8, a read-only role, a 200-row cap, and a 10-second statement_timeout. Peak connection count from the agent went from 60-plus to 8, the median tool call dropped from 340 ms to 22 ms because the handshake disappeared, and the application stopped falling over. None of it made the agent less capable. It made the agent survivable.