โ† Back to Blog
AI EngineeringAgentsTools

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.

By Joinloopยท24 June 2026ยท8 min read
ShareLinkedInXReddit
๐Ÿ”Œ

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: true for 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.0 flow on the HTTP transport for team use
  • Publish to the MCP registry so colleagues can mcp install your 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.

ShareLinkedInXReddit

Want to build this yourself?

Join a live cohort and go from reading about AI to shipping it, with practitioners who do it daily.