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"

Quick Summary

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
Parameterized Queries

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

Project Setup

1

Scaffold with mcp-framework

npx mcp-framework create database-server
cd database-server
npm install better-sqlite3
npm install -D @types/better-sqlite3
2

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;
Security First

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;
Schema Introspection Tools

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);
Connection Pooling

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:

  1. Call list_tables to see available tables
  2. Call describe_table with table: "users" to see the schema
  3. Call query with sql: "SELECT * FROM users WHERE role = ?" and params: ["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."

Frequently Asked Questions