I built a custom MCP server in 30 minutes and Claude gained superpowers
I was frustrated that Claude Desktop could search the web but couldn't query my internal Postgres database. Then I discovered the Model Context Protocol — and thirty minutes later, Claude could run SQL queries, read my project files, and call my internal APIs. MCP is the most underrated thing Anthropic has shipped. Here is exactly how to build your own server.
What MCP actually is
MCP (Model Context Protocol) is an open standard that lets LLMs connect to external data sources and tools through a unified interface. Think of it as a USB-C port for AI — any MCP server can plug into any MCP client (Claude Desktop, Claude Code, or your own app).
The server exposes three things:
- Tools — functions Claude can call (like querying a database or calling an API)
- Resources — data Claude can read (like files or configuration)
- Prompts — reusable prompt templates Claude can invoke
The minimal MCP server
Install the SDK and create your first server:
npm install @modelcontextprotocol/sdk
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
const server = new Server(
{ name: 'my-internal-tools', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
// Declare tools Claude can use
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'query_database',
description: 'Run a read-only SQL query against the internal Postgres database',
inputSchema: {
type: 'object',
properties: {
sql: {
type: 'string',
description: 'The SQL query to execute (SELECT only)',
},
},
required: ['sql'],
},
},
],
}));
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'query_database') {
const { sql } = request.params.arguments as { sql: string };
// Safety check — only allow SELECT
if (!sql.trim().toUpperCase().startsWith('SELECT')) {
return {
content: [{ type: 'text', text: 'Error: Only SELECT queries are allowed' }],
isError: true,
};
}
// Execute against your actual DB
const rows = await runQuery(sql);
return {
content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }],
};
}
return { content: [{ type: 'text', text: 'Unknown tool' }], isError: true };
});
// Start server
const transport = new StdioServerTransport();
await server.connect(transport);
Connecting to Claude Desktop
Edit your Claude Desktop config at ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"my-internal-tools": {
"command": "node",
"args": ["/path/to/your/server/dist/index.js"],
"env": {
"DATABASE_URL": "postgresql://localhost:5432/mydb"
}
}
}
}
Restart Claude Desktop and you will see a hammer icon — Claude now has access to your tools.
A practical example: Postgres query tool
Here is the full database query implementation I actually use:
import pg from 'pg';
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 3,
idleTimeoutMillis: 30000,
});
async function runQuery(sql: string): Promise[]> {
const client = await pool.connect();
try {
// Additional safety: wrap in read-only transaction
await client.query('BEGIN TRANSACTION READ ONLY');
const result = await client.query(sql);
await client.query('ROLLBACK');
return result.rows;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
Adding multiple tools
The real power is combining tools. Here I add a second tool to fetch from an internal API:
// In ListToolsRequestSchema handler, add:
{
name: 'get_user_activity',
description: 'Get recent activity for a user from the internal analytics API',
inputSchema: {
type: 'object',
properties: {
userId: { type: 'string' },
days: { type: 'number', description: 'Number of days to look back (max 30)' },
},
required: ['userId'],
},
},
// In CallToolRequestSchema handler, add:
if (request.params.name === 'get_user_activity') {
const { userId, days = 7 } = request.params.arguments as {
userId: string;
days?: number;
};
const cappedDays = Math.min(days, 30);
const response = await fetch(
`${process.env.ANALYTICS_API_URL}/users/${userId}/activity?days=${cappedDays}`,
{ headers: { Authorization: `Bearer ${process.env.ANALYTICS_API_KEY}` } }
);
const data = await response.json();
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
}
Resources: exposing read-only data
Resources are ideal for configuration files, documentation, or schemas that Claude should always have access to:
import { ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import fs from 'fs/promises';
import path from 'path';
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [
{
uri: 'file:///schema/database.sql',
name: 'Database Schema',
description: 'Current database schema with all tables and columns',
mimeType: 'text/plain',
},
],
}));
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
if (request.params.uri === 'file:///schema/database.sql') {
const schema = await fs.readFile(
path.join(process.cwd(), 'schema', 'database.sql'),
'utf-8'
);
return {
contents: [{ uri: request.params.uri, mimeType: 'text/plain', text: schema }],
};
}
throw new Error('Resource not found');
});
What changed after building this
Before the MCP server, asking Claude "how many users signed up last week?" required me to run the query myself and paste the result. Now I ask the question and Claude runs the query, interprets the result, and follows up with insights.
More importantly, Claude can chain queries. "Show me the users who signed up last week and had no activity in the first 48 hours" becomes a multi-step investigation where Claude writes and refines SQL until it gets the answer — without me touching a terminal.
Security considerations
A few things I enforce in every MCP server I build:
- Read-only database transactions — mutations require a separate confirmed tool
- API key stored in environment, never hardcoded
- Rate limiting on expensive tools (max 10 calls per minute)
- Log every tool call with timestamp and arguments for audit
Try it today
The MCP SDK is well-documented and the starter template is under 100 lines. If you have a database, API, or file system that Claude should be able to query, you can have a working server this afternoon. The ROI on a 30-minute build is enormous when you use it every day.