Build a Database Query MCP Server
Create an MCP server that lets AI assistants query SQLite and PostgreSQL databases. Learn to build safe, parameterized query tools with proper input validation and result formatting.
title: "Build a Database Query MCP Server" description: "Create an MCP server that lets AI assistants query SQLite and PostgreSQL databases. Learn to build safe, parameterized query tools with proper input validation and result formatting." order: 3 keywords:
- mcp database server
- sqlite mcp server
- postgresql mcp tools
- database query ai
- mcp server intermediate date: "2026-04-01" level: "intermediate" duration: "30 min"
Build an MCP server that gives AI assistants the ability to query databases safely. You will create tools for executing read-only SQL queries against SQLite, with patterns that extend to PostgreSQL. Covers parameterized queries, schema introspection, and result formatting.
What You Will Build
A database MCP server that provides three tools:
- query -- Execute read-only SQL queries
- list_tables -- List all tables in the database
- describe_table -- Get the schema of a specific table
SQL queries where user-supplied values are passed as separate parameters rather than concatenated into the SQL string. This prevents SQL injection attacks and is essential for any database tool exposed to AI assistants.
Prerequisites
- Completed a beginner MCP tutorial
- Node.js 18+
- Basic SQL knowledge
Project Setup
Scaffold with mcp-framework
npx mcp-framework create database-server
cd database-server
npm install better-sqlite3
npm install -D @types/better-sqlite3
Create a sample database
Create scripts/seed.ts to set up a test database:
import Database from "better-sqlite3";
const db = new Database("sample.db");
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
role TEXT DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
owner_id INTEGER REFERENCES users(id),
status TEXT DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT OR IGNORE INTO users (name, email, role) VALUES
('Alice Johnson', 'alice@example.com', 'admin'),
('Bob Smith', 'bob@example.com', 'user'),
('Carol White', 'carol@example.com', 'user');
INSERT OR IGNORE INTO projects (name, owner_id, status) VALUES
('MCP Server', 1, 'active'),
('Data Pipeline', 2, 'active'),
('Legacy Migration', 1, 'completed');
`);
db.close();
console.log("Database seeded successfully");
Run it:
npx tsx scripts/seed.ts
Building the Tools
QueryTool
Create src/tools/QueryTool.ts:
import { MCPTool } from "mcp-framework";
import { z } from "zod";
import Database from "better-sqlite3";
class QueryTool extends MCPTool<typeof inputSchema> {
name = "query";
description = "Execute a read-only SQL query against the database. Only SELECT statements are allowed.";
schema = {
sql: {
type: z.string().min(1),
description: "The SQL SELECT query to execute",
},
params: {
type: z.array(z.union([z.string(), z.number(), z.null()])).optional(),
description: "Optional query parameters for parameterized queries",
},
};
async execute(input: z.infer<typeof inputSchema>): Promise<string> {
const { sql, params } = input;
// Security: only allow SELECT statements
const normalizedSql = sql.trim().toLowerCase();
if (!normalizedSql.startsWith("select")) {
return JSON.stringify({
error: "Only SELECT queries are allowed. Use read-only access.",
});
}
// Block dangerous keywords
const forbidden = ["drop", "delete", "insert", "update", "alter", "create", "exec"];
for (const keyword of forbidden) {
if (normalizedSql.includes(keyword)) {
return JSON.stringify({
error: `Query contains forbidden keyword: ${keyword}`,
});
}
}
try {
const db = new Database("sample.db", { readonly: true });
const stmt = db.prepare(sql);
const rows = params ? stmt.all(...params) : stmt.all();
db.close();
return JSON.stringify({
rowCount: rows.length,
rows: rows,
}, null, 2);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return JSON.stringify({ error: `Query failed: ${message}` });
}
}
}
const inputSchema = z.object({
sql: z.string().min(1),
params: z.array(z.union([z.string(), z.number(), z.null()])).optional(),
});
export default QueryTool;
Always enforce read-only access when exposing databases to AI assistants. Open the database in readonly mode, validate queries, and block mutation keywords. The AI model does not have malicious intent, but prompt injection attacks could trick it into executing harmful queries.
ListTablesTool
Create src/tools/ListTablesTool.ts:
import { MCPTool } from "mcp-framework";
import { z } from "zod";
import Database from "better-sqlite3";
class ListTablesTool extends MCPTool<typeof inputSchema> {
name = "list_tables";
description = "List all tables in the database with their row counts";
schema = {};
async execute(): Promise<string> {
try {
const db = new Database("sample.db", { readonly: true });
const tables = db.prepare(`
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
ORDER BY name
`).all() as { name: string }[];
const result = tables.map((table) => {
const count = db.prepare(
`SELECT COUNT(*) as count FROM "${table.name}"`
).get() as { count: number };
return { name: table.name, rowCount: count.count };
});
db.close();
return JSON.stringify(result, null, 2);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return JSON.stringify({ error: message });
}
}
}
const inputSchema = z.object({});
export default ListTablesTool;
DescribeTableTool
Create src/tools/DescribeTableTool.ts:
import { MCPTool } from "mcp-framework";
import { z } from "zod";
import Database from "better-sqlite3";
class DescribeTableTool extends MCPTool<typeof inputSchema> {
name = "describe_table";
description = "Get the schema of a specific table including columns, types, and constraints";
schema = {
table: {
type: z.string().min(1),
description: "The table name to describe",
},
};
async execute(input: z.infer<typeof inputSchema>): Promise<string> {
try {
const db = new Database("sample.db", { readonly: true });
const columns = db.prepare(
`PRAGMA table_info("${input.table}")`
).all() as {
cid: number;
name: string;
type: string;
notnull: number;
dflt_value: string | null;
pk: number;
}[];
if (columns.length === 0) {
db.close();
return JSON.stringify({ error: `Table "${input.table}" not found` });
}
const foreignKeys = db.prepare(
`PRAGMA foreign_key_list("${input.table}")`
).all();
db.close();
return JSON.stringify({
table: input.table,
columns: columns.map((col) => ({
name: col.name,
type: col.type,
nullable: !col.notnull,
defaultValue: col.dflt_value,
primaryKey: !!col.pk,
})),
foreignKeys,
}, null, 2);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return JSON.stringify({ error: message });
}
}
}
const inputSchema = z.object({
table: z.string().min(1),
});
export default DescribeTableTool;
Always include schema introspection tools (list_tables, describe_table) alongside query tools. AI assistants need to understand the database structure before writing meaningful queries.
Official SDK Alternative
Here is the same server using the official TypeScript SDK for comparison:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import Database from "better-sqlite3";
const server = new McpServer({
name: "database-server",
version: "1.0.0",
});
server.tool(
"query",
"Execute a read-only SQL SELECT query",
{
sql: z.string().min(1).describe("SQL SELECT query"),
params: z.array(z.union([z.string(), z.number(), z.null()])).optional(),
},
async ({ sql, params }) => {
const normalized = sql.trim().toLowerCase();
if (!normalized.startsWith("select")) {
return {
content: [{ type: "text" as const, text: '{"error":"Only SELECT allowed"}' }],
isError: true,
};
}
const db = new Database("sample.db", { readonly: true });
const rows = params
? db.prepare(sql).all(...params)
: db.prepare(sql).all();
db.close();
return {
content: [{ type: "text" as const, text: JSON.stringify({ rowCount: rows.length, rows }, null, 2) }],
};
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch(console.error);
Extending to PostgreSQL
The patterns above work for any SQL database. For PostgreSQL, swap better-sqlite3 for pg:
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 5,
});
// In your tool execute method:
const result = await pool.query(sql, params);
return JSON.stringify({
rowCount: result.rowCount,
rows: result.rows,
}, null, 2);
For PostgreSQL, always use a connection pool instead of creating new connections for each query. This dramatically improves performance when the AI assistant makes multiple queries in sequence.
Testing Your Server
npm run build
npx @modelcontextprotocol/inspector node dist/index.js
Try these queries in the inspector:
- Call
list_tablesto see available tables - Call
describe_tablewithtable: "users"to see the schema - Call
querywithsql: "SELECT * FROM users WHERE role = ?"andparams: ["admin"]
Claude Desktop Configuration
{
"mcpServers": {
"database": {
"command": "node",
"args": ["/path/to/database-server/dist/index.js"]
}
}
}
Now you can ask Claude: "What tables are in the database? Show me all admin users."