Building MCP Servers with Database Backends for Secure Data Access
Learn how to architect MCP servers that safely integrate with databases, implement secure connection pooling, and enforce fine-grained access control patterns.
The Model Context Protocol (MCP) is transforming how AI systems interact with external tools. But when you expose a database through an MCP server, security and performance become non-negotiable. This guide covers production patterns for building MCP servers that safely surface database functionality to AI agents.
Why Database-Backed MCP Servers Matter
AI agents operating on databases need:
- Controlled schema exposure: not all tables and columns belong in agent-accessible tools
- Connection efficiency: agents spawn many concurrent requests; naive pooling causes failures
- Audit trails: every query an agent runs must be logged and traceable
- Fine-grained access control: different agents or users see different rows and columns
A raw database connection in an agent is a foot-gun. An MCP server acts as the trusted intermediary.
Architecture: The Three-Tier Pattern
Agent → MCP Server → Database
(isolation boundary)
Your MCP server is the security checkpoint. It:
- Authenticates the connecting client (the agent or orchestrator)
- Parses and validates tool requests before forwarding to the database
- Enforces row-level and column-level access control
- Logs all operations for compliance and debugging
- Manages connection pooling to prevent resource exhaustion
This pattern is borrowed from traditional application security but adapted for the agent context where the "client" is an AI system, not a human.
Setting Up Connection Pooling
Never create a new database connection per request. Here's a minimal Node.js/TypeScript example using pg (PostgreSQL) and node-pg-pool:
import { Pool } from 'pg';
const pool = new Pool({
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME,
max: 20, // max connections in pool
idleTimeoutMillis: 30000, // close idle connections after 30s
connectionTimeoutMillis: 2000,
});
// Test the pool on startup
pool.query('SELECT 1').catch(err => {
console.error('Database connection failed:', err);
process.exit(1);
});
export { pool };
The max: 20 limit prevents agent bursts from exhausting your database. The idleTimeoutMillis reclaims unused connections.
Defining Safe Tool Schemas
Not every database operation should be agent-accessible. Define your MCP tools explicitly:
export const tools: Tool[] = [
{
name: "query_users_by_region",
description: "Find users in a specific region with optional filtering by status",
inputSchema: {
type: "object",
properties: {
region: {
type: "string",
enum: ["us-west", "us-east", "eu", "apac"],
description: "Geographic region to query"
},
status: {
type: "string",
enum: ["active", "inactive"],
description: "Filter by user status (optional)"
}
},
required: ["region"]
}
}
];
By restricting inputs to enums and specific fields, you prevent SQL injection and limit what data an agent can access.
Implementing Query Execution with Validation
Before executing any query, validate the input against your schema. Use a whitelist approach:
async function queryUsersByRegion(
input: { region: string; status?: string }
): Promise<unknown> {
// Validate region (enum checked by MCP, but double-check)
const validRegions = ["us-west", "us-east", "eu", "apac"];
if (!validRegions.includes(input.region)) {
throw new Error(`Invalid region: ${input.region}`);
}
// Build query with parameterized statements (prevents SQL injection)
let query = "SELECT id, name, email, region FROM users WHERE region = $1";
const params: unknown[] = [input.region];
if (input.status) {
if (!["active", "inactive"].includes(input.status)) {
throw new Error(`Invalid status: ${input.status}`);
}
query += " AND status = $2";
params.push(input.status);
}
query += " LIMIT 1000"; // Always enforce a limit
const result = await pool.query(query, params);
return result.rows;
}
Always use parameterized queries ($1, $2). Never concatenate user input into SQL strings.
Schema Management and Versioning
Your MCP tool schema should map cleanly to your database schema, but abstract implementation details:
// What the agent sees (MCP schema)
const selectableColumns = {
users: ["id", "name", "email", "created_at"],
orders: ["id", "user_id", "total", "status", "created_at"],
};
// What's not exposed
const hiddenColumns = {
users: ["password_hash", "ssn", "internal_notes"],
orders: ["stripe_secret_id", "payment_token"],
};
When you need to evolve your database schema, version your MCP tools. Deprecate old tools gradually:
{
name: "query_users_v2",
description: "Query users (v2 - preferred)",
// ... v2 schema
},
{
name: "query_users_v1",
description: "Query users (v1 - deprecated, use query_users_v2)",
// ... v1 schema
}
Audit Logging
Every query must be logged with context for compliance:
async function logQuery(
agentId: string,
toolName: string,
input: unknown,
result: unknown,
error?: Error
): Promise<void> {
const logEntry = {
timestamp: new Date().toISOString(),
agent_id: agentId,
tool_name: toolName,
input_summary: JSON.stringify(input),
result_count: Array.isArray(result) ? result.length : 0,
error_message: error?.message || null,
};
// Write to immutable log (append-only file, CloudWatch, or Kafka)
await auditLog.write(logEntry);
}
Store logs in an append-only system so agents (and rogue admins) can't tamper with them.
Graceful Shutdown and Resource Cleanup
When your MCP server receives a SIGTERM (deployment or restart), close the pool cleanly:
async function gracefulShutdown(): Promise<void> {
console.log("Shutting down gracefully...");
await pool.end();
console.log("Database pool closed");
process.exit(0);
}
process.on("SIGTERM", gracefulShutdown);
process.on("SIGINT", gracefulShutdown);
This ensures in-flight queries complete before connections close, avoiding agent-visible errors.
Testing Your MCP Server
Test with realistic agent behavior: many concurrent requests, timeout scenarios, malformed input:
import { describe, it, expect } from "vitest";
describe("Database MCP Server", () => {
it("rejects out-of-enum region values", async () => {
await expect(
queryUsersByRegion({ region: "north-korea" })
).rejects.toThrow("Invalid region");
});
it("returns at most 1000 rows", async () => {
const result = await queryUsersByRegion({ region: "us-west" });
expect(result.length).toBeLessThanOrEqual(1000);
});
it("handles concurrent requests without deadlock", async () => {
const promises = Array(100)
.fill(null)
.map(() => queryUsersByRegion({ region: "eu" }));
await expect(Promise.all(promises)).resolves.toBeDefined();
});
});
Production Checklist
[ ]Connection poolmaxsized for your database (start at 20, tune based on metrics)[ ]All user inputs validated against defined enums/schemas before query execution[ ]Parameterized queries ($1,$2) — no string concatenation[ ]Row limits enforced (LIMIT,OFFSET)[ ]Audit logging captures agent ID, timestamp, and query context[ ]Hidden columns explicitly excluded from tool schemas[ ]Graceful shutdown on SIGTERM with connection cleanup[ ]Load testing with concurrent agents to validate pooling[ ]Read-only database credentials for agents (if possible)[ ]Monitoring on pool utilization, query latency, and errors
Database-backed MCP servers are powerful, but they're security-sensitive. Treat them like you'd treat a traditional API server — validate inputs, enforce limits, log everything, and design for failure. Your agents (and your compliance team) will thank you.