Building MCP Servers: Give Your AI Agent Real Tools
MCP (Model Context Protocol) is how you connect an LLM to your database, API, or internal tooling. Here's a practical guide to building a production-ready MCP server from scratch.
Model Context Protocol (MCP) has become the de-facto standard for wiring AI agents to the real world. Over 5,000 MCP servers exist today; every major AI coding tool โ Claude Code, Cursor, Windsurf โ supports them. If you're building AI products, you need to understand how to write one.
This guide skips the theory. You'll build a working MCP server that exposes a database query tool and a file-writing resource, then connect it to Claude Desktop or Claude Code locally.
What MCP actually is
An MCP server is a small process that speaks JSON-RPC 2.0. It registers tools (things the LLM can call), resources (read-only data it can fetch), and prompts (reusable templates). The host app โ Claude, Cursor, your own agent โ discovers these capabilities and decides when to call them.
Think of it this way: without MCP, every tool integration is bespoke glue code. With MCP, you write the server once and any compliant client can use it.
The three primitive types:
| Primitive | Direction | Examples |
|---|---|---|
| Tools | LLM โ Server (write/action) | Run a query, send an email, call an API |
| Resources | Server โ LLM (read-only) | Read a file, fetch a dashboard, stream logs |
| Prompts | Server โ LLM (templates) | Code review template, summarisation prompt |
Setting up
You'll need Node.js 22+ or Python 3.11+. The official SDKs are @modelcontextprotocol/sdk (TypeScript) and mcp (Python). This guide uses TypeScript.
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"strict": true
}
}
The minimal server
Create src/server.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "my-tools",
version: "1.0.0",
});
// Register a tool
server.tool(
"add",
"Add two numbers together",
{ a: z.number(), b: z.number() },
async ({ a, b }) => ({
content: [{ type: "text", text: String(a + b) }],
})
);
// Start the server over stdio (for local use)
const transport = new StdioServerTransport();
await server.connect(transport);
Run it:
npx tsx src/server.ts
That's a working MCP server. The StdioServerTransport makes it compatible with Claude Desktop and Claude Code out of the box โ both spawn the server as a subprocess and communicate over stdin/stdout.
A realistic tool: querying a database
Here's a more production-shaped example โ a tool that runs a read-only SQL query against a Postgres database.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const server = new McpServer({
name: "postgres-tools",
version: "1.0.0",
});
server.tool(
"query_db",
"Run a read-only SQL query and return results as JSON. Only SELECT statements are permitted.",
{ sql: z.string().describe("A SELECT statement to execute") },
async ({ sql }) => {
// Prevent writes โ cheap defence-in-depth
const normalised = sql.trim().toUpperCase();
if (!normalised.startsWith("SELECT")) {
return {
content: [{ type: "text", text: "Error: only SELECT statements are allowed" }],
isError: true,
};
}
try {
const result = await pool.query(sql);
return {
content: [
{
type: "text",
text: JSON.stringify(result.rows, null, 2),
},
],
};
} catch (err) {
return {
content: [{ type: "text", text: `Query error: ${(err as Error).message}` }],
isError: true,
};
}
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
Key points:
- Tool descriptions matter a lot. The LLM uses the description โ and the parameter descriptions you add via
.describe()โ to decide when and how to call the tool. Be precise. - Always validate inputs. Zod schemas are enforced by the SDK before your handler runs, but defensive checks inside the handler (like the
SELECT-only guard) protect against prompt injection where the LLM is tricked into passing a malicious query. - Return
isError: truefor application-level failures. This signals to the LLM that the call failed without crashing the session.
Exposing a resource
Resources are ideal for data the LLM should read contextually rather than query on demand โ things like a schema file, a runbook, or the current state of a dashboard.
server.resource(
"db-schema",
"postgres://schema", // URI โ can be any string the client uses to reference it
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
`);
const schema = result.rows
.reduce((acc: Record<string, string[]>, row) => {
(acc[row.table_name] ??= []).push(`${row.column_name} (${row.data_type})`);
return acc;
}, {});
return {
contents: [
{
uri: "postgres://schema",
mimeType: "text/plain",
text: Object.entries(schema)
.map(([table, cols]) => `${table}:\n ${cols.join("\n ")}`)
.join("\n\n"),
},
],
};
}
);
When the user says "what tables do we have?", the LLM can fetch this resource instead of blindly guessing column names.
Connecting to Claude Desktop
Add your server to ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"postgres-tools": {
"command": "npx",
"args": ["tsx", "/path/to/my-mcp-server/src/server.ts"],
"env": {
"DATABASE_URL": "postgresql://localhost:5432/mydb"
}
}
}
}
Restart Claude Desktop. You'll see a hammer icon in the chat input โ that's your tools appearing. For Claude Code, add the same config under ~/.claude/claude_desktop_config.json or use claude mcp add.
Deploying over HTTP (for remote use)
Stdio works great locally but you'll want HTTP for shared team servers or cloud deployment. The SDK's StreamableHTTPServerTransport handles this:
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
const server = buildServer(); // your McpServer factory
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3000);
For stateless servers (no per-session data), pass sessionIdGenerator: undefined โ each request is fully independent, which means you can scale horizontally behind a load balancer without sticky sessions.
Add authentication at the HTTP layer (Bearer token, mTLS) rather than inside the MCP handler. The MCP spec doesn't mandate auth โ it's your transport responsibility.
Common pitfalls
| Pitfall | What goes wrong | Fix |
|---|---|---|
| Vague tool descriptions | LLM calls the wrong tool or passes wrong args | Describe edge cases, units, and constraints explicitly |
| No input validation | Prompt injection passes malicious SQL, paths, etc. | Zod schemas + explicit allow-lists in handlers |
| Returning huge payloads | Long context windows get stuffed; costs spike | Paginate results; return summaries with a cursor |
| Stateful stdio servers that crash | Session dies, no reconnect | Keep handlers stateless; re-open DB connections on demand |
| Exposing write tools without auth | LLM can delete production data | Require OAuth or API key on HTTP transport; scope tools to roles |
Testing your server
The MCP Inspector is the fastest way to iterate:
npx @modelcontextprotocol/inspector npx tsx src/server.ts
This opens a browser UI where you can call tools directly, inspect responses, and see the JSON-RPC messages flying back and forth โ no need to wire up a full LLM client during development.
For automated tests, use the in-process client:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "test-client", version: "1.0.0" });
const server = buildServer();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
const result = await client.callTool({ name: "query_db", arguments: { sql: "SELECT 1 AS val" } });
console.log(result); // { content: [{ type: "text", text: '[{"val":1}]' }] }
No subprocess, no network โ fast unit tests for every tool.
When to build an MCP server vs. a function-calling wrapper
Both approaches give the LLM tools to call. The difference is reuse and standardisation:
| Direct function calling | MCP server | |
|---|---|---|
| Setup | Inline in your agent code | Separate process with SDK |
| Reusability | One agent only | Any MCP-compatible client |
| Discovery | Hardcoded in prompt | Dynamic โ client queries the server |
| Best for | Single-app, tight coupling | Shared tooling, multi-client |
Build an MCP server when you want the tool to be accessible from Claude Desktop, Claude Code, Cursor, and your own agent all at once. For a tool that's only ever called from one place, function calling is simpler.
Next steps
The server above gets you to a working prototype in under an hour. From there:
- Add an
OAuth 2.0flow on the HTTP transport for team use - Publish to the MCP registry so colleagues can
mcp installyour server - Add structured logging so you can trace which tool calls the LLM made and why
If you want to go deeper on building production AI systems โ agents, evals, RAG pipelines, and the operational patterns that make them reliable in production โ the Joinloop AI Engineering cohort covers all of this hands-on over eight weeks.
Want to build this yourself?
Join a live cohort and go from reading about AI to shipping it, with practitioners who do it daily.