How to Build an MCP Server: A Technical Step-by-Step Guide

How to Build an MCP Server: A Technical Step-by-Step Guide
This site contains affiliate links. We may earn a commission at no extra cost to you. How we review →

What Is MCP and Why Are Developers Building Servers for It?

If you're searching for how to build an MCP server, you're likely in one of two situations: you've started using an AI coding tool like Cursor or Windsurf and noticed that MCP unlocks integrations your existing workflows are missing, or you're building an AI-powered product and need a standardized way to expose your internal tools and data to LLM (Large Language Model) clients.

MCP — Model Context Protocol — is an open protocol published by Anthropic in late 2024. It defines how AI clients (models, agent frameworks, IDEs) discover and invoke external tools, read resources, and use reusable prompts. The analogy Anthropic uses is USB-C: a universal connector so any compliant AI client can plug into any compliant server without custom integration code. That's the pitch, anyway. The reality is more nuanced — the ecosystem is still maturing, edge cases exist, and not every MCP client implements the full spec. But the protocol itself is solid, and building an MCP server is now a practical skill worth having.

This guide walks you through the full process: protocol architecture, transport options, implementation with the TypeScript SDK, and the specific failure modes you'll hit if you skip the nuance. This is not a toy "hello world" walkthrough — it's what you need to build something production-worthy.

MCP Architecture: What You're Actually Building

The Three Primitives

Before writing a line of code, you need to understand what an MCP server can actually expose. The protocol defines three primitives:

  • Tools: Callable functions. The AI sends a tool name plus a JSON-structured argument object, the server executes logic and returns a result. Think of these as the action surface — searching a database, creating a GitHub issue, sending an email.
  • Resources: URI-addressable, readable data. The AI can list available resources and read their contents. File systems, database records, and API responses can be exposed this way. Resources are passive — the AI reads them; it doesn't invoke them like a function.
  • Prompts: Parameterized prompt templates the server publishes for clients to inject into conversations. Less commonly implemented, but useful when you want to standardize how an AI approaches a recurring task.

Most practical MCP servers start with tools only. Resources and prompts are optional extensions. This guide focuses on tools, which is where 90% of the real value lives.

Transport: stdio vs. SSE

MCP supports two transport mechanisms, and choosing the wrong one causes significant pain later:

Transport How It Works Best For Limitation
stdio Server runs as child process; client communicates via stdin/stdout Local desktop integrations (Cursor, Claude Desktop, Windsurf) Single client only; not accessible remotely
SSE (Server-Sent Events) Server runs as HTTP service; client connects via SSE + POST endpoints Remote/hosted servers, multi-client access, cloud deployments Requires hosting, authentication, TLS

If you're building for local developer tooling — a Cursor extension that queries your internal Confluence or your local Postgres — start with stdio. If you're building a hosted service that multiple users will connect to, you need SSE. The TypeScript SDK handles both; you swap the transport layer without rewriting your tool logic.

Prerequisites and Project Setup

This guide uses the official TypeScript SDK (@modelcontextprotocol/sdk). Python SDK examples follow the same conceptual pattern — check the official MCP documentation for Python-specific syntax, as both SDKs are under active development and minor APIs shift between versions.

You'll need:

  • Node.js 18+ (the SDK uses native fetch and ES module syntax)
  • TypeScript 5.0+ recommended
  • Basic familiarity with async/await and JSON Schema

Initialize your project:

mkdir my-mcp-server
cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node ts-node
npx tsc --init

In your tsconfig.json, ensure "module": "node16" or "nodenext" and "moduleResolution" to match. The SDK is ESM-first. If you see module resolution errors, this is almost always the cause.

Building Your First MCP Server: stdio Transport

Here's a minimal but structurally complete MCP server that exposes two tools: one that searches a mock knowledge base and one that creates a task in a mock task system. Replace the mock implementations with your real logic.

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";

// Define your server
const server = new Server(
  {
    name: "my-mcp-server",
    version: "1.0.0",
  },
  {
    capabilities: {
      tools: {},
    },
  }
);

// Tool definitions — these get sent to the client during discovery
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "search_knowledge_base",
        description:
          "Search the internal knowledge base for articles and documentation. Use this when the user asks about internal processes, policies, or technical documentation.",
        inputSchema: {
          type: "object",
          properties: {
            query: {
              type: "string",
              description: "The search query string",
            },
            max_results: {
              type: "number",
              description: "Maximum number of results to return (1-20)",
              default: 5,
            },
          },
          required: ["query"],
        },
      },
      {
        name: "create_task",
        description:
          "Create a new task in the project management system. Use this when the user explicitly asks to create, add, or log a task.",
        inputSchema: {
          type: "object",
          properties: {
            title: {
              type: "string",
              description: "Short task title (max 100 characters)",
            },
            description: {
              type: "string",
              description: "Detailed task description",
            },
            priority: {
              type: "string",
              enum: ["low", "medium", "high", "critical"],
              description: "Task priority level",
            },
          },
          required: ["title", "priority"],
        },
      },
    ],
  };
});

// Tool execution handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  if (name === "search_knowledge_base") {
    // Validate with Zod at runtime
    const input = z
      .object({
        query: z.string().min(1),
        max_results: z.number().min(1).max(20).default(5),
      })
      .parse(args);

    // Replace with your real search logic
    const results = await searchKnowledgeBase(input.query, input.max_results);

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(results, null, 2),
        },
      ],
    };
  }

  if (name === "create_task") {
    const input = z
      .object({
        title: z.string().min(1).max(100),
        description: z.string().optional(),
        priority: z.enum(["low", "medium", "high", "critical"]),
      })
      .parse(args);

    const task = await createTask(input);

    return {
      content: [
        {
          type: "text",
          text: `Task created successfully. ID: ${task.id}, Title: "${task.title}"`,
        },
      ],
    };
  }

  throw new Error(`Unknown tool: ${name}`);
});

// Mock implementations — replace these
async function searchKnowledgeBase(query: string, maxResults: number) {
  return {
    results: [
      { id: "1", title: "Example Article", excerpt: `Result for: ${query}`, score: 0.95 },
    ].slice(0, maxResults),
    total: 1,
  };
}

async function createTask(input: { title: string; description?: string; priority: string }) {
  return { id: `TASK-${Date.now()}`, ...input, created_at: new Date().toISOString() };
}

// Boot the server
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("MCP server running on stdio"); // Use stderr for logs — stdout is protocol
}

main().catch((err) => {
  console.error("Fatal error:", err);
  process.exit(1);
});

Critical detail: Use console.error for all your own logging, never console.log. In stdio mode, stdout carries protocol messages. Writing arbitrary text to stdout corrupts the protocol stream and causes cryptic parse failures on the client side — one of the most common first-time debugging pitfalls.

Writing Effective Tool Descriptions

The quality of your tool descriptions directly determines whether the AI calls the right tool at the right time. This is not documentation for humans — it's the primary signal the model uses to decide when to invoke your tool versus relying on its own knowledge.

Follow these rules:

  • Be specific about trigger conditions. "Use this when the user asks about X" is more reliable than "Returns information about X."
  • Disambiguate between similar tools. If you have both search_docs and get_doc_by_id, the descriptions must make it clear when to use each.
  • Describe return format in the description. Tell the model what shape of data to expect so it can use the result correctly.
  • Keep input schema descriptions precise. Vague field descriptions lead to the AI passing malformed arguments.

Adding Error Handling and Input Validation

Your tool handler will receive raw JSON from the AI. Treat it like user input from the internet: validate everything. The Zod examples above are the right pattern — parse at the handler boundary before touching any business logic.

For error responses, MCP has a specific format. Return structured errors rather than throwing unhandled exceptions:

// For expected/recoverable errors, return an error content block
return {
  content: [
    {
      type: "text",
      text: "No results found for that query. Try broadening your search terms.",
    },
  ],
  isError: true, // signals to the client that this is an error response
};

// For unexpected errors, throw — the SDK will format the protocol error
throw new Error("Database connection failed");

Use isError: true for business logic errors the AI can recover from (empty results, invalid search terms, permission denied). Reserve thrown exceptions for infrastructure failures. The distinction matters because a well-behaved AI client will retry or adjust its approach on isError responses, while protocol errors may halt the agent loop.

Connecting to Claude Desktop and Cursor

Once your server runs without errors (npx ts-node src/index.ts should start without output to stdout), you register it with your MCP client.

Claude Desktop

Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or the equivalent path on Windows/Linux:

{
  "mcpServers": {
    "my-mcp-server": {
      "command": "node",
      "args": ["/absolute/path/to/my-mcp-server/dist/index.js"],
      "env": {
        "DATABASE_URL": "your-connection-string"
      }
    }
  }
}

Restart Claude Desktop. If the server appears in the tools panel, your handshake succeeded. If not, check ~/Library/Logs/Claude/mcp*.log — Claude writes detailed MCP startup and protocol logs there.

Cursor

In Cursor settings, navigate to the MCP section and add your server. Cursor supports the same stdio configuration format. As of mid-2025, Cursor's MCP support is available on all tiers including the free plan, though behavior with the specific model versions (Cursor Pro at $20/month uses Claude Sonnet under the hood for agent tasks) may vary. Verify current MCP support in Cursor's changelog — this area has seen frequent updates.

Switching to SSE Transport for Remote Deployment

If you need a hosted, multi-client MCP server, swap the transport layer. Your tool logic doesn't change:

import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";

const app = express();
const transports = new Map<string, SSEServerTransport>();

app.get("/sse", async (req, res) => {
  const transport = new SSEServerTransport("/messages", res);
  const sessionId = transport.sessionId;
  transports.set(sessionId, transport);

  const serverInstance = createServer(); // same server setup as before
  await serverInstance.connect(transport);

  res.on("close", () => {
    transports.delete(sessionId);
  });
});

app.post("/messages", express.json(), async (req, res) => {
  const sessionId = req.query.sessionId as string;
  const transport = transports.get(sessionId);
  if (!transport) return res.status(404).send("Session not found");
  await transport.handlePostMessage(req, res);
});

app.listen(3000);

For production SSE deployments, you need authentication (API key header validation at minimum), TLS termination, and session cleanup logic. The SDK doesn't provide these — they're your responsibility at the HTTP layer.

Debugging with MCP Inspector

The MCP Inspector is the fastest way to validate your server without connecting a full AI client:

npx @modelcontextprotocol/inspector node /path/to/dist/index.js

This opens a browser UI at localhost:5173 where you can list tools, fill in arguments manually, and see raw request/response JSON. Run this before connecting any AI client — it eliminates 80% of integration debugging by isolating whether the problem is your server or the client.

When Building an MCP Server Is NOT the Right Choice

MCP is not the right abstraction for every problem. Be specific about when to walk away:

1. You Only Have One Integration Target

If you're building a tool exclusively for one application that already has a native plugin system — a VS Code extension, a custom LangChain tool, a CrewAI tool class — native plugin APIs are simpler and better supported. MCP's value is standardization across multiple clients. If you have one client, you're adding protocol overhead for no benefit.

2. High-Frequency, Low-Latency Tool Calls

MCP has per-call overhead: JSON serialization, protocol framing, potential process spawning for stdio. For tools called dozens of times per second in a tight agent loop, you'll feel the latency. Direct in-process function calls or a binary RPC (Remote Procedure Call) framework are more appropriate at that frequency.

3. Your Security Model Requires Fine-Grained, Per-Call Authorization

MCP doesn't have a built-in authorization layer. The spec defines transport-level security but not per-tool permission scopes, user-level access control, or audit logging. If your environment requires a detailed audit trail of which AI invoked which tool on behalf of which user with which permissions — a common enterprise requirement — you're building that authorization layer yourself on top of MCP. At that point, evaluate whether a purpose-built AI gateway product handles your access control requirements more cleanly.

4. You Need Stateful, Multi-Turn Tool Interactions

Each MCP tool call is effectively stateless from the protocol's perspective. If your workflow requires a multi-step transaction — begin a database transaction, perform operations, then commit or rollback — you have to manage that state entirely within your server and expose compound tools. Some teams find this awkward compared to agent frameworks that have native support for stateful task execution, like Devin's shell session model.

5. The MCP Client Ecosystem Doesn't Cover Your Target Platform

As of mid-2025, solid MCP client support exists in Claude Desktop, Cursor, Windsurf, and a handful of open-source agent frameworks. If your target environment is a mobile app, a custom web chat interface, or a legacy enterprise system, you'll need to build MCP client support yourself — which may be more work than just calling your API directly. The ecosystem is expanding but verify support in your specific target before committing to MCP as the integration layer.

Production Considerations

A few things that matter when you move past local testing:

  • Input sanitization for database tools: Never interpolate AI-generated values directly into SQL or shell commands. Always use parameterized queries. The AI is not adversarial, but it can produce unexpected inputs that break naive string interpolation.
  • Timeouts: Set explicit timeouts on all external calls inside your tools. An AI agent waiting indefinitely for a hung database query will stall the entire agent loop. Return a timeout error with a clear message so the agent can decide to retry or move on.
  • Response size: MCP responses pass through the AI's context window. A tool that returns 50,000 tokens of raw database output will either get truncated or blow out the context. Paginate results, summarize where possible, and be deliberate about what you return.
  • Versioning: The version field in your server declaration matters as the protocol evolves. Use semantic versioning and document your changelog. Clients may make compatibility decisions based on declared version.

Bottom Line

Building an MCP server is genuinely straightforward if you understand the transport model, write careful tool descriptions, and validate inputs rigorously. The TypeScript SDK handles protocol mechanics — your actual work is defining clean tool interfaces and writing reliable backend logic. A well-built MCP server connecting Claude or Cursor to your internal systems — databases, APIs, documentation, project management — is one of the more leverage-rich infrastructure investments a small team can make right now.

The caveat: MCP is still an evolving spec. The core protocol is stable, but the SDK APIs, client-side support, and surrounding tooling shift with some frequency. Before building on any specific SDK version or client behavior, check the official MCP documentation and the SDK changelog for breaking changes. What's documented here reflects the state of the protocol and SDK as of mid-2025 — specific API signatures may have changed.

Disclosure: We earn referral commissions from select partners including Cursor and Windsurf. This doesn't influence our technical recommendations — we link to tools we'd recommend regardless.

FAQ

What is an MCP server and how does it differ from a regular API?
An MCP (Model Context Protocol) server exposes tools, resources, and prompts to AI clients over a standardized protocol. Unlike a REST API that any client can call, an MCP server is specifically designed to be discovered and invoked by AI models and agent frameworks like Claude, Cursor, or Windsurf. The protocol handles capability negotiation, schema exposure, and structured tool-call/response cycles that REST doesn't formalize.
What programming languages can I use to build an MCP server?
Anthropic maintains official SDKs for TypeScript/Node.js and Python. Community SDKs exist for Rust, Go, Java, Kotlin, and C#. The TypeScript SDK is the most mature and has the largest surface area of protocol features covered. Python SDK is close behind and is the better choice if your tooling is already Python-based.
Do I need to host my MCP server publicly for it to work?
No. MCP servers can run locally via stdio transport, which is how most desktop integrations (Cursor, Claude Desktop) work. The server runs as a child process on the user's machine and communicates over stdin/stdout. Public hosting is only required if you want remote access, multi-user deployments, or server-sent events (SSE) transport.
How do I debug an MCP server during development?
The MCP Inspector (npx @modelcontextprotocol/inspector) is the primary debugging tool. It provides a browser UI to manually invoke tools, inspect schemas, and see raw request/response payloads. For stdio servers, you can also pipe logs to stderr — stdout is reserved for protocol messages. Claude Desktop writes MCP logs to a platform-specific log directory you can tail.
Can I expose database access through an MCP server?
Yes, and this is one of the most common use cases. You define tools like query_database or get_record, validate inputs with JSON Schema, execute the underlying query, and return structured results. The critical safety consideration: never pass raw AI-generated SQL directly to a database. Use parameterized queries, allowlists of permitted operations, and rate limiting at the MCP layer.
What's the difference between MCP tools, resources, and prompts?
Tools are callable functions the AI invokes to take actions or retrieve data (e.g., search_docs, create_issue). Resources are URI-addressable data the AI can read passively (e.g., file contents, database records exposed as resource URIs). Prompts are reusable, parameterized prompt templates the server exposes for the client to inject into conversations. Most MCP servers start with tools only — resources and prompts are optional.

Related reads

Across the Wild Run AI network