
Build an MCP Server to Connect Claude to Your Database
A practical tutorial on the Model Context Protocol: build a TypeScript MCP server that lets Claude query your database safely, with tools and guardrails.
Key takeaways
- An MCP server advertises tools that a model's client can call mid-conversation, and this one exposes read-only list_tables and run_query tools over Postgres.
- A read-only Postgres role granted SELECT on specific tables is the real security boundary, not the SELECT-matching regex, which only catches honest mistakes.
- Add guardrails like a 5-second statement_timeout, a 200-row cap, and zod input validation to keep responses safe and affordable.
- Log to stderr instead of stdout, since the stdio transport owns stdout and a stray console.log corrupts the message stream.
Overview
The Model Context Protocol is a standard way to hand a model a set of tools and data sources without baking them into the model itself: you run a small server that advertises some tools, the model's client connects to it, and the model can call those tools mid-conversation. That's it. It's a thin contract between "the thing that wants to do work" and "the thing that knows how to do it." In this tutorial we'll build one of those servers in TypeScript. It exposes two read-only tools over your database, one to list tables, one to run a query that we restrict to SELECT, and we'll wire it to Claude through Claude Desktop so you can ask questions about your data in plain language.
I'll be honest up front about why this is worth doing carefully. The moment you let a model write the queries, you've handed query authorship to something that is occasionally confidently wrong. The interesting engineering isn't the wiring, that part is short. It's the guardrails. So we'll build the happy path, then spend real time on the ways it can go sideways.
What you'll need
- Node.js 18 or newer, and a working TypeScript setup. The MCP SDK ships types, so TypeScript pays off here.
- A Postgres database you can connect to. A read-only replica or a local copy is ideal, more on that later.
- Claude Desktop installed, or any other MCP client you prefer. The config format is the same idea everywhere; I'll show the Claude Desktop file specifically.
- About twenty minutes.
We're using Postgres because it's common and the pg driver has clean parameterized queries. The approach maps onto MySQL or SQLite with small changes.
Step 1: set up the project
Make a directory and install the two dependencies, the MCP SDK and the Postgres driver.
mkdir claude-db-server && cd claude-db-server
npm init -y
npm install @modelcontextprotocol/sdk pg zod
npm install -D typescript @types/node @types/pg
npx tsc --init
A couple of tsconfig.json settings matter for ES modules, which the SDK uses. Set "module": "Node16" (or NodeNext) and "target": "ES2022", and add "type": "module" to your package.json. If you skip this you'll hit import errors that look mysterious and aren't.
zod is in there because we'll describe each tool's inputs with a zod schema. The SDK uses that schema to validate arguments before your handler ever runs, which is the first of several guardrails.
Step 2: create the server and connect a Postgres pool
Here's the skeleton. We create a connection pool once, at startup, and reuse it for every tool call, opening a fresh connection per query is a waste and will eventually exhaust your database's connection limit under any real load.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import pg from "pg";
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 5,
// Bound how long a single query may run. A model that writes a
// cartesian join shouldn't be able to pin your database for a minute.
statement_timeout: 5_000,
});
const server = new McpServer({
name: "claude-db-server",
version: "1.0.0",
});
The connection string comes from an environment variable, not a literal in the file. That's not just hygiene, when you wire this to Claude Desktop, the config is where the credential lives, and you'll want a credential scoped to read-only access. We'll come back to that.
statement_timeout is the kind of thing that's easy to leave out and painful to learn about in production. It tells Postgres to abort any single statement that runs longer than five seconds. The model doesn't know your indexes; it will occasionally produce a query that scans a table the size of a small country. This caps the damage.
Step 3: register the list_tables tool
The first tool tells Claude what's in the database. Without it, the model is guessing at table and column names, and it guesses badly. Give it a way to look.
server.registerTool(
"list_tables",
{
description:
"List all tables in the database with their columns and types. " +
"Call this first to understand the schema before writing a query.",
inputSchema: {},
},
async () => {
const result = await pool.query(`
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'public'
ORDER BY table_name, ordinal_position
`);
return {
content: [
{ type: "text", text: JSON.stringify(result.rows, null, 2) },
],
};
},
);
A few things worth noticing. The description is doing real work, it's the only thing the model reads to decide when to reach for this tool, so the instruction to "call this first" actually changes behavior. The inputSchema is empty because this tool takes no arguments. And the return shape is the MCP convention: a content array of typed blocks. We're returning text, which here happens to be JSON the model can read.
I've scoped this to the public schema and the columns table. If your database has dozens of schemas or hundreds of tables, dumping all of it is both noisy and a small information-disclosure decision you should make on purpose. You might filter to an allowlist of tables instead. We'll revisit that under gotchas.
Step 4: register the run_query tool with a SELECT-only guard
This is the one that matters. We let the model send a SQL string, and we run it, but only if it's a single SELECT.
server.registerTool(
"run_query",
{
description:
"Run a read-only SQL SELECT query against the database and return " +
"the rows. Only SELECT statements are permitted. Use parameter " +
"placeholders ($1, $2, ...) for any values and pass them in `params`.",
inputSchema: {
sql: z.string().describe("A single SELECT statement."),
params: z
.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))
.optional()
.describe("Values for the $1, $2, ... placeholders in the query."),
},
},
async ({ sql, params }) => {
const guardError = rejectIfNotSelect(sql);
if (guardError) {
return {
isError: true,
content: [{ type: "text", text: guardError }],
};
}
const result = await pool.query({
text: sql,
values: params ?? [],
rowMode: "array",
});
const MAX_ROWS = 200;
const rows = result.rows.slice(0, MAX_ROWS);
const truncated = result.rows.length > MAX_ROWS;
return {
content: [
{
type: "text",
text: JSON.stringify(
{
columns: result.fields.map((f) => f.name),
rows,
row_count: result.rows.length,
truncated,
},
null,
2,
),
},
],
};
},
);
And the guard it calls:
function rejectIfNotSelect(sql: string): string | null {
// Strip line and block comments so they can't hide a second statement.
const stripped = sql
.replace(/--.*$/gm, "")
.replace(/\/\*[\s\S]*?\*\//g, "")
.trim();
// Reject anything with a statement separator — one query per call.
if (stripped.replace(/;\s*$/, "").includes(";")) {
return "Only a single statement is allowed. Remove the ';'.";
}
if (!/^select\b/i.test(stripped) && !/^with\b/i.test(stripped)) {
return "Only SELECT (or WITH ... SELECT) queries are permitted.";
}
// Belt and suspenders: block keywords that mutate state, even though
// a read-only DB role should already prevent them.
if (/\b(insert|update|delete|drop|alter|truncate|grant|revoke|create)\b/i.test(stripped)) {
return "Write and DDL statements are not permitted.";
}
return null;
}
Let me say something uncomfortable about this function: string-matching SQL is not a security boundary. It's a usability layer. A determined adversary, or just an unlucky generation, can find phrasings that slip past a regex, that's the whole history of SQL parsing in one sentence. The guard is here to catch the model's honest mistakes early and give it a clear error to recover from, not to be the thing standing between an attacker and your data. The thing standing between an attacker and your data is the database role, which we get to next. Treat the regex as a friendly bouncer, not a vault door.
Notice the query uses params for values. We're telling the model, through the tool description, to parameterize. When it does, pg sends the values separately from the query text and Postgres never confuses data for code. The row cap is the other quiet guardrail, a SELECT * on a million-row table will otherwise stuff a million rows into the model's context, which is slow, expensive, and useless. We return 200 and flag that we truncated.
Step 5: connect over stdio and start
The transport is how the client talks to your server. For a local server launched by Claude Desktop, that's stdio, the client spawns your process and pipes JSON-RPC over stdin and stdout.
const transport = new StdioServerTransport();
await server.connect(transport);
One rule that bites everyone once: because the protocol owns stdout, you cannot console.log for debugging. A stray log line corrupts the message stream and the client drops the connection with an unhelpful error. Log to stderr instead (console.error) or to a file.
Compile and you have a runnable server:
npx tsc
You can sanity-check it before involving Claude by running the SDK's inspector:
npx @modelcontextprotocol/inspector node build/index.js
The inspector gives you a UI to list the tools and call them by hand. If list_tables returns your schema there, the server works; anything wrong afterward is a config problem, not a code problem, which is a useful thing to know.
Step 6: wire it to Claude Desktop
Claude Desktop reads a JSON config that tells it which MCP servers to launch. On macOS it lives at ~/Library/Application Support/Claude/claude_desktop_config.json; on Windows it's under %APPDATA%\Claude\. Add your server under mcpServers:
{
"mcpServers": {
"database": {
"command": "node",
"args": ["/absolute/path/to/claude-db-server/build/index.js"],
"env": {
"DATABASE_URL": "postgresql://readonly_user:password@localhost:5432/mydb"
}
}
}
}
Use an absolute path for the args, Claude Desktop doesn't run from your project directory, so a relative path won't resolve. The env block is where the connection string goes, and this is the natural place to point at a read-only user. Restart Claude Desktop fully (quit, not just close the window), and you should see the tools appear. Now you can ask things like "which customers signed up last week and haven't placed an order?" and watch Claude call list_tables, then run_query, then explain the result.
The same shape works for other clients, Cursor, Cline, your own code using an MCP client library. The keys differ slightly but it's always: a command to launch, args, and an environment.
Gotchas
The SELECT regex is not your security model. I said it above and I'll repeat it because it's the mistake I see most. Create a dedicated Postgres role with GRANT SELECT on exactly the tables you want exposed and nothing else, no write privileges, no access to other schemas, no superuser. REVOKE the rest. Then even a query that defeats the regex can't do anything the role can't do. The regex catches mistakes; the role enforces the boundary. If you only do one thing from this post, do this one.
Returning too many rows hurts in three ways. It's slow, it costs tokens, and past a point it actively degrades the answer because the relevant rows get buried. The row cap handles the common case, but consider also nudging the model in the tool description to add LIMIT clauses and to aggregate rather than fetch raw rows when it just needs a count. A good list_tables plus a clear description does more here than any single limit.
Authentication and exposure. A stdio server launched locally inherits your trust, it's only as exposed as your machine. The story changes completely if you run the server remotely over HTTP for several users. Then you need real authentication on the transport, per-user database credentials so one user's question can't read another's data, and you have to assume the SQL strings arriving are adversarial. Don't quietly promote a local stdio prototype to a shared HTTP service without redoing the threat model.
Schema disclosure is a choice. list_tables hands the model your entire public schema, table names, column names and all. Column names leak intent, a table with ssn and card_last_four tells a story. If any of that is sensitive, filter the schema query to an allowlist rather than dumping information_schema wholesale. The model can only ask about what you let it see.
Comments and multiple statements. SQL comments can hide a second statement, and a trailing semicolon plus another query is a classic way to smuggle work past a naive check. We strip comments and reject statement separators before the keyword check for exactly this reason. It's still not a parser, which is exactly why the read-only role carries the real weight.
Wrapping up
The server is genuinely small, two tools, a pool, a transport, maybe eighty lines. What makes it production-shaped is the layering: a zod schema validates the input, a regex rejects the obvious non-SELECT cases with a message the model can act on, a row cap keeps responses sane, and underneath all of it a read-only database role does the actual enforcing. Each layer is doing a different job, and the only one you can't remove is the database role.
Start with a read-only replica and a locked-down user, get list_tables and run_query working through the inspector, then hand it to Claude. Once you trust the read path, adding more tools, a saved-query tool, a schema-search tool, a cached-aggregates tool, is the same pattern repeated. The hard part, the part you just did, was deciding exactly how much you're willing to let the model do, and building the fences before you needed them.