Introduction
If you've spent any time working with Claude Code from the terminal, you've probably hit the same wall eventually: the agent is brilliant at reasoning over the code in front of it, but it has no idea what's actually happening inside your staging database, your internal ticketing system, or that one legacy service only your team can reach. You end up copy-pasting query results into the chat, or worse, hand-rolling one-off shell scripts every time you need Claude to "just check the database."
This is exactly the gap that the Model Context Protocol (MCP) was designed to close. MCP gives you a standard, well-typed way to expose your own tools, resources, and data sources directly to an agent like Claude Code, so instead of describing your database schema in a prompt, Claude can call a tool and inspect it live.
In this tutorial, we're going to build a real, working MCP server from scratch in TypeScript. It will expose a small set of PostgreSQL inspection tools — listing tables, describing schemas, and running sanitized read-only queries — over stdio, and we'll wire it into Claude Code's local configuration so you can debug a database directly from your terminal agent. By the end, you'll have a reusable pattern you can extend to any internal system: an internal REST API, a Redis cache, a CI pipeline, or a proprietary CLI tool.
This is a hands-on, code-first guide. We'll cover the underlying concepts just enough to make good decisions, then spend most of our time in the editor.
What Is MCP, Really?
The Model Context Protocol is an open specification, originally introduced by Anthropic, that standardizes how AI applications ("hosts," like Claude Code or Claude Desktop) discover and call external capabilities exposed by "servers." Think of it as the USB-C of AI tooling — instead of every agent framework inventing its own plugin format, MCP defines one JSON-RPC-based contract that any compliant client can speak.
An MCP server can expose three main primitives:
- Tools — functions the model can invoke, with a name, description, and a strongly typed input schema. This is what we'll focus on today.
- Resources — read-only data the client can attach to context, like a file or a database snapshot.
- Prompts — reusable prompt templates the host can surface to the user.
Communication happens over a transport. For local developer tooling — the case we care about — that transport is almost always stdio: the host process spawns your server as a child process and talks to it over standard input/output using JSON-RPC messages. There's no networking, no ports to manage, and no authentication handshake to build yourself, which makes stdio the simplest and most secure option for anything that only needs to run on your own machine.
For remote or shared servers, MCP also supports an HTTP-based transport, but that introduces a whole additional layer of concerns (auth, session management, deployment) that's out of scope for a local developer tool like the one we're building here.
Why Build a Custom MCP Server?
You might be wondering whether you really need a custom server, given how many community MCP servers already exist for Postgres, GitHub, Slack, and so on. There are a few scenarios where rolling your own is the right call:
- Private infrastructure. Your production database lives behind a VPN, your internal API has a bespoke auth scheme, or your schema conventions are specific to your company. No generic server will understand that context.
- Strict guardrails. Generic database MCP servers are often built to be flexible, which means permissive. When you build your own, you decide exactly which operations are allowed — read-only inspection, for instance, with zero write access.
- Custom workflows. Maybe you want a single tool that runs a health check across three different systems and returns a consolidated report. That's a business-logic decision only you can encode.
- Debuggability and trust. When something goes wrong, you want to be the one who can read the fifty lines of code between "Claude called a tool" and "a database query ran," not decipher a third-party package.
Our running example — a PostgreSQL inspection server — is deliberately realistic. Database debugging is one of the most common reasons developers reach for an agent mid-incident: "why is this query slow," "what does this table actually look like," "did that migration actually run." Giving Claude Code direct, safe visibility into the database turns a slow back-and-forth into a single conversational debugging session.
Architecture Overview
Before writing code, it helps to see the whole picture:
┌─────────────────┐ stdio (JSON-RPC) ┌───────────────────────┐
│ Claude Code │ <-----------------------------> │ Your MCP Server │
│ (host process) │ spawns as child process │ (Node.js / TS) │
└─────────────────┘ └───────────┬───────────┘
│ pg (node-postgres)
▼
┌───────────────────────┐
│ PostgreSQL Database │
│ (read-only role) │
└───────────────────────┘
Claude Code spawns your server process, performs the MCP initialize handshake, asks for the list of available tools, and from then on calls those tools whenever the model decides they're relevant to the conversation. Your server is responsible for validating inputs, talking to Postgres, and returning structured results — nothing more.
Prerequisites
Before you start, make sure you have:
- Node.js 20 or later
- A package manager (
pnpm,npm, oryarn— examples below usenpm) - Claude Code installed and authenticated locally
- Access to a PostgreSQL instance (a local Docker container is perfect for following along)
- Basic familiarity with TypeScript and async/await
If you want a disposable database to experiment with, spin one up quickly:
docker run --name mcp-demo-db \
-e POSTGRES_PASSWORD=devpassword \
-e POSTGRES_DB=appdb \
-p 5432:5432 \
-d postgres:16
Step 1: Scaffold the Project
Let's create a fresh project and install our dependencies. We need the official MCP SDK, Zod for schema validation, and pg for talking to Postgres.
mkdir mcp-postgres-inspector && cd mcp-postgres-inspector
npm init -y
npm install @modelcontextprotocol/sdk zod pg
npm install -D typescript tsx @types/node @types/pg
Create a minimal tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
And set up your project's package.json scripts:
{
"type": "module",
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
Note the "type": "module" — the MCP SDK ships as ESM, so keeping your project consistent here will save you import headaches later.
Step 2: Create the Server Skeleton
The modern TypeScript SDK exposes a high-level McpServer class that handles the JSON-RPC plumbing for you. You register tools declaratively, and the SDK takes care of responding to tools/list and tools/call requests, validating arguments against your schema before your handler ever runs.
Create src/index.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new McpServer({
name: "postgres-inspector",
version: "1.0.0",
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
// Never write to stdout directly — it's reserved for JSON-RPC frames.
console.error("postgres-inspector MCP server running on stdio");
}
main().catch((err) => {
console.error("Fatal error starting MCP server:", err);
process.exit(1);
});
That last comment is worth repeating because it trips up almost everyone the first time: stdout is the wire protocol. Any stray console.log will corrupt the JSON-RPC stream and silently break your server from the client's perspective. Always route logs through console.error, which writes to stderr and is safe to use freely.
Step 3: Connect to PostgreSQL Safely
Next, set up a dedicated database module. Critically, this connection should use a read-only database role — defense in depth matters more than clever application-level checks.
-- Run this once against your database as an admin
CREATE ROLE mcp_readonly WITH LOGIN PASSWORD 'change-me';
GRANT CONNECT ON DATABASE appdb TO mcp_readonly;
GRANT USAGE ON SCHEMA public TO mcp_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_readonly;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO mcp_readonly;
Now create src/db.ts:
import pg from "pg";
const { Pool } = pg;
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 5,
idleTimeoutMillis: 30_000,
statement_timeout: 5_000, // hard cap: kill runaway queries after 5s
});
export async function withReadOnlyTransaction<T>(
fn: (client: pg.PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
// Belt-and-suspenders: even if the role were misconfigured,
// the transaction itself refuses write statements.
await client.query("BEGIN TRANSACTION READ ONLY");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
Two things are doing real security work here, not just style:
statement_timeoutprevents a single expensive query from hanging your server or the database.BEGIN TRANSACTION READ ONLYgives you a database-enforced guarantee against writes, independent of whatever role permissions you configured.
Step 4: Enforce Strict Query Sanitization
This is the section to read twice. Giving an autonomous agent the ability to run arbitrary SQL is a real risk — not because the model is malicious, but because it can misinterpret ambiguous context, hallucinate a destructive statement, or be steered by a prompt injection hidden in a file it read earlier in the session. Our job is to make the "correct" input the only possible input.
We'll apply sanitization in layers:
// src/sanitize.ts
const FORBIDDEN_KEYWORDS =
/\b(insert|update|delete|drop|alter|truncate|grant|revoke|create|copy|call|do|vacuum)\b/i;
const MAX_QUERY_LENGTH = 2000;
export class UnsafeQueryError extends Error {}
export function assertReadOnlyQuery(rawQuery: string): string {
const query = rawQuery.trim();
if (query.length === 0) {
throw new UnsafeQueryError("Query cannot be empty.");
}
if (query.length > MAX_QUERY_LENGTH) {
throw new UnsafeQueryError(
`Query exceeds maximum length of ${MAX_QUERY_LENGTH} characters.`
);
}
// Reject stacked statements — one query per call, no exceptions.
const withoutTrailingSemicolon = query.replace(/;\s*$/, "");
if (withoutTrailingSemicolon.includes(";")) {
throw new UnsafeQueryError("Multiple statements are not permitted.");
}
if (!/^\s*(select|with)\b/i.test(withoutTrailingSemicolon)) {
throw new UnsafeQueryError("Only SELECT and WITH (CTE) queries are allowed.");
}
if (FORBIDDEN_KEYWORDS.test(withoutTrailingSemicolon)) {
throw new UnsafeQueryError("Query contains a disallowed keyword.");
}
return withoutTrailingSemicolon;
}
Note the layered approach: we don't rely on the keyword blocklist alone (blocklists are famously easy to bypass with clever formatting), we combine it with:
- A strict allowlist requiring the statement to start with
SELECTorWITH. - Rejecting semicolon-separated statement stacking.
- A hard length cap to make obfuscated payloads harder to construct.
- The database-level
READ ONLYtransaction from Step 3 as a final backstop.
This is defense in depth: no single layer needs to be perfect, because the layers don't share the same failure mode.
Step 5: Register Your Tools
Now let's wire it all together in src/index.ts. We'll expose three tools: list_tables, describe_table, and run_query.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { pool, withReadOnlyTransaction } from "./db.js";
import { assertReadOnlyQuery, UnsafeQueryError } from "./sanitize.js";
const server = new McpServer({
name: "postgres-inspector",
version: "1.0.0",
});
server.registerTool(
"list_tables",
{
description:
"List all tables in the public schema, including approximate row counts.",
inputSchema: {},
},
async () => {
const { rows } = await pool.query(`
SELECT relname AS table_name, n_live_tup AS approx_row_count
FROM pg_stat_user_tables
ORDER BY relname;
`);
return {
content: [{ type: "text", text: JSON.stringify(rows, null, 2) }],
};
}
);
server.registerTool(
"describe_table",
{
description:
"Show column names, types, and nullability for a given table.",
inputSchema: {
tableName: z
.string()
.regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, "Invalid table name format")
.describe("The name of the table to describe, e.g. 'orders'"),
},
},
async ({ tableName }) => {
const { rows } = await pool.query(
`SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = $1
ORDER BY ordinal_position;`,
[tableName]
);
if (rows.length === 0) {
return {
content: [{ type: "text", text: `No table named "${tableName}" was found.` }],
isError: true,
};
}
return {
content: [{ type: "text", text: JSON.stringify(rows, null, 2) }],
};
}
);
server.registerTool(
"run_query",
{
description:
"Run a single read-only SELECT query against the database and return up to 100 rows. INSERT, UPDATE, DELETE, DDL, and multi-statement queries are rejected.",
inputSchema: {
sql: z.string().describe("A single SELECT or WITH query"),
},
},
async ({ sql }) => {
try {
const safeQuery = assertReadOnlyQuery(sql);
const rows = await withReadOnlyTransaction(async (client) => {
const result = await client.query(`${safeQuery} LIMIT 100`);
return result.rows;
});
return {
content: [{ type: "text", text: JSON.stringify(rows, null, 2) }],
};
} catch (err) {
if (err instanceof UnsafeQueryError) {
return {
content: [{ type: "text", text: `Query rejected: ${err.message}` }],
isError: true,
};
}
throw err;
}
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("postgres-inspector MCP server running on stdio");
}
main().catch((err) => {
console.error("Fatal error starting MCP server:", err);
process.exit(1);
});
A few design choices worth calling out:
- We validate
tableNameagainst a strict regex rather than trusting string interpolation anywhere near SQL, even for a supposedly "safe" identifier. describe_tablereturnsisError: trueinstead of throwing when a table doesn't exist — this is a normal, expected outcome the model should be able to reason about, not a crash.run_queryappends a hardLIMIT 100server-side. Never trust the model (or the user) to remember a limit clause; enforce it where you control execution.
Step 6: Test with the MCP Inspector
Before wiring anything into Claude Code, verify the server works in isolation using the official MCP Inspector, an interactive tool for calling your server's tools directly.
DATABASE_URL="postgresql://mcp_readonly:change-me@localhost:5432/appdb" \
npx @modelcontextprotocol/inspector npx tsx src/index.ts
This opens a local web UI where you can call list_tables, describe_table, and run_query by hand and inspect the raw JSON responses. Get comfortable here first — it's far faster to catch a bug in the Inspector than to debug it through a live Claude Code session.
Step 7: Configure Claude Code to Use Your Server
With the server verified, register it with Claude Code using the claude mcp add command. The -- separates Claude Code's own flags from the command used to launch your server:
claude mcp add postgres-inspector \
--scope project \
-e DATABASE_URL="postgresql://mcp_readonly:change-me@localhost:5432/appdb" \
-- npx tsx src/index.ts
A few notes on scope:
--scope local(the default) stores the server privately for just you, in this project.--scope projectwrites to a.mcp.jsonfile at your project root, which you can commit so your whole team gets the same tool automatically.--scope userregisters the server globally across every project on your machine — useful for something like a personal Postgres inspector you use everywhere.
If you'd rather configure it by hand, a project-scoped .mcp.json looks like this:
{
"mcpServers": {
"postgres-inspector": {
"type": "stdio",
"command": "npx",
"args": ["tsx", "src/index.ts"],
"env": {
"DATABASE_URL": "postgresql://mcp_readonly:change-me@localhost:5432/appdb"
}
}
}
}
For production or shared team use, don't hardcode credentials into .mcp.json if it's going to be committed — reference an environment variable that's already present in each developer's shell instead, or load it from a local .env file that's gitignored.
Restart Claude Code (or run /mcp inside a session) to confirm the server connects and its tools appear in the tool list.
Real-World Example: An Agentic Debugging Session
Here's what this actually looks like in practice. Imagine a teammate reports that the orders endpoint is returning stale totals. From the terminal, inside Claude Code, you might say:
"Something's off with order totals in production-mirror data. Check the
orderstable structure, then look for orders wheretotal_amountdoesn't match the sum of their line items."
With the MCP server registered, Claude Code can now:
- Call
describe_tablewithordersto understand the schema without you pasting it in. - Call
describe_tablewithorder_line_itemsto find the relationship. - Construct a
run_querycall with aSELECT ... GROUP BY ... HAVINGstatement joining the two tables. - Summarize the mismatched rows back to you in plain language, with the actual query it ran shown for your review.
Because every one of those steps runs through your sanitized, read-only tool, you get the speed of an autonomous agent with the safety guarantees of a human-reviewed query. That combination — fast iteration without giving up control — is the entire point of building your own MCP server instead of trusting a generic one.
Best Practices
- Default to read-only. Only add write capabilities behind an explicit, separate tool with its own confirmation step and its own narrowly scoped credentials — never blend reads and writes into one generic "run_sql" tool.
- Validate at the boundary, not just in your head. Use Zod schemas for every tool input; don't rely on the model to send well-formed data just because your description asked nicely.
- Return structured, truncated output. Cap row counts and payload size. A 50,000-row JSON blob wastes context window and is not something the model can reason about usefully anyway.
- Write descriptive tool descriptions. The model chooses when to call a tool based largely on its
descriptionfield. Be specific about what the tool does and doesn't do — "read-only" and "up to 100 rows" are useful hints to include directly in the text. - Log to stderr, always. Reserve stdout exclusively for the JSON-RPC transport.
- Use least-privilege database roles. Application-level checks are a second line of defense, not the first.
- Version your server. Bump the
versionfield in yourMcpServerconstructor when you change tool behavior, so debugging a regression is easier down the line. - Keep tools narrow and composable. Three small, well-named tools (
list_tables,describe_table,run_query) are easier for the model to use correctly than one do-everythingdatabase_toolwith a dozen optional parameters.
Common Mistakes to Avoid
- Writing to stdout for debugging. This is the single most common bug when building your first stdio MCP server — a leftover
console.logwill corrupt every message after it and produce confusing, hard-to-diagnose client errors. - Trusting a keyword blocklist alone. Blocklists (
no INSERT, no DELETE) can be bypassed with comments, whitespace tricks, or case variation. Pair them with an allowlist and database-level enforcement, as we did above. - Skipping the
LIMITclause. Without a server-enforced cap, a broad query can return enormous result sets that blow out context windows or your database's memory. - Using a superuser or app-owner role for the MCP connection. If your sanitization logic ever has a gap, the blast radius should be limited to "can read tables," not "can drop the database."
- Forgetting
statement_timeout. A single slow query can otherwise hang your entire server process, since a typicalpg.Poolhandles a limited number of concurrent connections. - Vague tool descriptions. A tool named
querywith a description of "runs a query" gives the model far less signal than "Run a single read-only SELECT query, max 100 rows, no writes permitted." - Committing secrets in
.mcp.json. If you use project scope and commit the file, keep credentials in environment variables, not inline strings.
🚀 Pro Tips
- Add a
ping-style lightweight tool (e.g.,check_connection) that just runsSELECT 1. It's an easy way to verify your server and database connectivity are healthy before debugging anything more complex. - Use
zod's.describe()on every field, not just the top-level tool description — those per-field descriptions get surfaced to the model too, and dramatically improve how correctly it fills out complex input schemas. - If you're inspecting multiple databases (staging, prod-replica, analytics), register one MCP server per environment with distinct names (
postgres-staging,postgres-analytics-ro) rather than adding an environment parameter to a single tool — this makes it far harder for the model to accidentally point a query at the wrong database. - Wrap your
pg.Poolerrors and return them as tool errors (isError: true) rather than letting them throw uncaught. A clean error message ("connection refused — is the tunnel open?") is far more actionable to the agent than a raw stack trace. - Keep a
CHANGELOG.mdin your MCP server repo. As you and your team add tools over time, it becomes the fastest way to answer "wait, when did we add write access to anything?"
📌 Key Takeaways
- MCP is the standard bridge between an agent like Claude Code and your own private systems — tools you register are called explicitly and predictably, not guessed at.
- The high-level
McpServerAPI withregisterTool()and Zod schemas is the modern, recommended pattern for TypeScript MCP servers, replacing the older low-levelServerandsetRequestHandlerapproach. stdiotransport is ideal for local developer tools: Claude Code manages the subprocess, and there's no networking surface to secure.- Security has to be layered — schema validation, an allowlist of query types, a read-only database role, a transaction-level
READ ONLYguarantee, and a row-count cap all need to hold independently. claude mcp addwith--scope projectis the fastest path to sharing a custom tool with your whole team through a committed.mcp.json.
Conclusion
Building a custom MCP server is one of the highest-leverage things you can do to make an agentic terminal workflow genuinely useful for your day-to-day engineering work. Instead of describing your systems to Claude Code in prose every session, you give it a small, well-defined, safety-checked set of capabilities it can call directly — and you get to decide exactly where the boundaries are.
The PostgreSQL inspector we built here is intentionally minimal, but the pattern generalizes cleanly: swap pg for an HTTP client and you have a tool for your internal API; swap it for ioredis and you have a cache inspector; swap it for a CLI wrapper and you have a way to safely expose your team's bespoke tooling. The SDK, the sanitization discipline, and the Claude Code configuration steps stay the same.
Start small, keep your tools narrow and read-only, and expand access deliberately as you build confidence in how the agent actually uses what you give it.