Shipping production MCP servers without the footguns
The MCP reference servers were never meant for production — here is what you actually have to build before a tool call can touch real data.
The DROP TABLE you didn’t approve
A model with a tool is a model with a hand on the keyboard. Most of the time it types the right thing. Occasionally it is confidently, fluently wrong — and if the tool on the other end is a thin wrapper around your database, “wrong” means a DELETE with no WHERE, a write to the prod config table, or a 4-million-row SELECT that pins your connection pool until the pager goes off.
The Model Context Protocol made it trivially easy to expose that hand. The official modelcontextprotocol/servers repo is full of clean reference implementations — Git, Memory, Sequential Thinking, Time, Fetch, Filesystem — and they are explicitly educational, not production-ready. The maintainers say so directly. A whole tier of older examples (GitHub, Slack, SQLite, Brave Search) was moved to servers-archived, which was archived back in May 2025 with no security guarantees attached. Plenty of teams copied those anyway.
The gap between “works in the inspector” and “safe behind a senior’s review” is where this post lives.
The problem: tools are an attack surface that talks back
Treat every tool input as untrusted. Not because the user is hostile — because the model is the user, and the model will hand you arguments that look plausible and are catastrophic. The standard threat-model framing applies cleanly: never pass tool input straight into a shell, a SQL string, or a filesystem path; scope credentials down to exactly what the tool needs; and never auto-approve destructive operations.
The SDKs give you the wiring, not the judgement. The TypeScript SDK (@modelcontextprotocol/sdk, currently 1.x, with a v2 line tracking the updated spec slated for later in 2026) and the Python side — both the official python-sdk and the standalone FastMCP, now on the 3.x line as of release 3.2.1 in April — generate JSON Schema from your types and validate inbound arguments against it. That stops malformed payloads. It does nothing about well-formed, semantically destructive ones. { "query": "DELETE FROM users" } is a perfectly valid string.
So the design rule is: the schema proves the shape, your code proves the intent.
Deep dive: read-only by default, writes by allowlist
The single highest-leverage decision is making every tool read-only unless a write is explicitly, narrowly permitted. Don’t expose one run_sql tool. Expose query (read path, hard-capped) and a small set of intent-specific write tools whose operations you can enumerate and reason about.
Here is the pattern I reach for with the TypeScript SDK — strict input validation, a statement allowlist, a hard row cap, and a wall-clock timeout, all returning structured errors instead of throwing raw:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const MAX_ROWS = 500;
const QUERY_TIMEOUT_MS = 5_000;
// Reject anything that isn't a single read. No semicolons, no DDL/DML.
const READ_ONLY = /^s*select/i;
const FORBIDDEN = /(insert|update|delete|drop|alter|truncate|grant|copy)/i;
const server = new McpServer({ name: "warehouse", version: "1.0.0" });
server.registerTool(
"query",
{
title: "Run a read-only SQL query",
description: "Executes a single SELECT against the analytics replica.",
inputSchema: {
sql: z.string().min(1).max(2_000),
params: z.array(z.union([z.string(), z.number()])).max(32).default([]),
},
},
async ({ sql, params }) => {
if (!READ_ONLY.test(sql) || FORBIDDEN.test(sql) || sql.includes(";")) {
return {
isError: true,
content: [{ type: "text", text: "Rejected: only a single SELECT is permitted." }],
};
}
try {
// Parameterised — never interpolate model output into the string.
const rows = await withTimeout(
readReplica.query({ text: sql, values: params, max: MAX_ROWS + 1 }),
QUERY_TIMEOUT_MS,
);
const truncated = rows.length > MAX_ROWS;
return {
structuredContent: { rowCount: Math.min(rows.length, MAX_ROWS), truncated },
content: [{ type: "text", text: JSON.stringify(rows.slice(0, MAX_ROWS)) }],
};
} catch (err) {
// Structured, model-readable failure — not a stack trace leak.
return {
isError: true,
content: [{ type: "text", text: `Query failed: ${asSafeMessage(err)}` }],
};
}
},
); Four things are doing real work here. The regex allowlist plus the semicolon ban kills statement stacking. The parameterised query keeps model output out of the SQL string entirely. MAX_ROWS + 1 lets you detect truncation and tell the model honestly that there’s more — instead of silently dropping rows or returning the whole table. And the isError: true payload matters: when a handler returns that flag the SDK passes the failure back to the model as a result it can read and recover from, rather than a transport-level crash. Note one sharp edge that landed in the SDK — unknown or disabled tools now come back as a JSON-RPC -32602 rejection, not a CallToolResult with isError, so client code that only inspected result.isError needs to also catch rejected promises.
The write side never gets a free-text SQL string at all. A refund_order tool takes { orderId, amountCents }, validates the amount against the order, checks an allowlist of order states, and writes through a service-role connection that only has UPDATE on the orders table. Least privilege is enforced at the database grant, not in a prompt.
Real-world impact: the failure you’re designing out
The destructive tool call is rarely a dramatic jailbreak. It’s mundane. The model summarises a Slack thread, decides the “cleanup” the thread discussed should happen now, and calls your delete_channel tool with a confident rationale. Every word of the explanation is coherent. The action is wrong.
You design this out structurally, not with better prompting:
- No destructive tool runs without an explicit allowlist of permitted targets and states. A delete tool that can hit any channel is a delete tool that will eventually hit the wrong one.
- Scope the credential, not the instruction. A read-only replica connection physically cannot mutate, no matter what the model asks. That’s worth more than any “do not delete” line in a system prompt.
- Rate-limit and budget tool calls. A loop that retries a failing write 200 times is its own incident.
This is also where local-first development pays off — being able to exercise a server against a disposable database before it ever sees prod is the difference between catching a footgun on your laptop and catching it in a postmortem. I keep my notes on running this stack locally close while building.
Snapshot the tool calls like they’re an API
Tools are an API — the model is just an unusually creative client. So test them like one. The pattern that has saved me most is snapshot/regression testing: call each tool with a fixed set of inputs, including the nasty ones, and assert on the exact structured result. When a refactor changes a row cap or an error string, the diff shows up in review instead of in production.
import pytest
from mcp.shared.memory import create_connected_server_and_client_session as client_for
from server import mcp # FastMCP instance
@pytest.mark.anyio
async def test_query_rejects_writes(snapshot):
async with client_for(mcp._mcp_server) as client:
result = await client.call_tool("query", {"sql": "DELETE FROM users"})
assert result.isError is True
# Lock the exact rejection so a regex tweak can't silently widen access.
assert result.content[0].text == snapshot
@pytest.mark.anyio
async def test_query_caps_rows(snapshot):
async with client_for(mcp._mcp_server) as client:
result = await client.call_tool("query", {"sql": "SELECT * FROM big_table"})
assert result.structuredContent["truncated"] is True
assert result.structuredContent == snapshot The point isn’t coverage theatre. It’s that your destructive-input cases — DELETE, statement stacking, oversized params, path traversal — become permanent, named tests. The day someone “simplifies” the validation regex, the snapshot for test_query_rejects_writes goes red.
Why it matters
MCP won the integration layer faster than the safety patterns matured around it, and the reference servers being explicitly non-production left a vacuum that copy-paste happily filled. The deltas that make a server shippable aren’t exotic: read-only by default, writes behind a typed allowlist, validation that proves intent and not just shape, hard timeouts and row caps, credentials scoped at the grant, structured errors the model can recover from, and snapshot tests that pin every one of those guarantees in place.
None of it is glamorous. All of it is the difference between a tool that answers questions and a tool that, one confident call at a time, becomes the incident. If you want the longer architectural treatment, it’s the through-line of the work I’ve been writing up.