Skip to main content

MCP Server Development: Building Custom Model Context Protocol Servers

Published: March 2, 2026 Updated: May 22, 2026 Larry Qu 12 min read

Introduction

The Model Context Protocol (MCP) has become the universal standard for connecting AI assistants to external tools and data sources. As of May 2026, the TypeScript SDK has crossed 150 million downloads, over 13,000 MCP servers exist on GitHub, 28% of Fortune 500 companies have implemented MCP servers in their AI stacks, and Gartner predicts 75% of API gateway vendors will include MCP support by end of 2026. In December 2025, Anthropic donated MCP to the Linux Foundation’s Agentic AI Foundation, cementing its vendor-neutral governance.

Build an MCP server once, and every MCP-compatible client — Claude Desktop, Claude Code, Cursor, VS Code, Windsurf, Zed, ChatGPT — can use it immediately. The protocol solves the N×M problem: without MCP, connecting N AI models to M tools requires N×M custom integrations. With MCP, the math reduces to N+M: build one server per tool, and every client can use every server.

This guide teaches you how to build custom MCP servers from scratch, covering both TypeScript and Python, both transport options, and production security. For a conceptual overview of MCP, see the MCP Complete Guide.

Understanding MCP Architecture

Protocol Overview

MCP uses a client-server architecture with three distinct roles:

Role Examples Responsibility
Host Claude Desktop, Cursor, VS Code Initiates connections, owns model and UI
Client One per connected server, inside the host JSON-RPC messaging, capability negotiation
Server Your process Exposes tools, resources, prompts over a transport

Connection Lifecycle

Host → Client → Initialize (version + capabilities)
              ← Negotiate (supported features)
              → Initialized notification
              ←→ JSON-RPC message exchange
              → Shutdown (either side)

The protocol is stateful — once a session is established, both sides maintain context. All communication uses JSON-RPC 2.0 with well-defined jsonrpc, method, params, and result/error fields.

Six Capabilities

MCP servers can expose three capabilities, and clients can optionally offer three back to servers:

Capability Controlled By Purpose
Tools Model (with user approval) Functions the AI can call — query DB, send email, call API
Resources Host (fetched as context) URI-addressed read-only data — files, docs, DB rows
Prompts User (slash commands) Reusable templates — /summarize, /review-pr
Sampling Server → Host model Server asks the host’s LLM to complete text mid-call
Roots Host → Server Host declares which URIs the server may operate on
Elicitation Server → User Server asks for additional user input mid-call

A practical heuristic: if the model needs to do something, make it a tool. If the model needs to read something, make it a resource. If the user wants a named shortcut, make it a prompt.

Transport Options

Transport Use When Notes
stdio Local servers, IDE integrations Server runs as subprocess, JSON-RPC over stdin/stdout
Streamable HTTP Remote servers, multi-user, production Single HTTP endpoint, POST for requests + optional SSE

Important: The HTTP+SSE transport from the original 2024-11-05 spec was deprecated in March 2025. Do not build new servers on it. Use Streamable HTTP for remote deployments.

Rule of thumb: start with stdio — zero network config, and the MCP Inspector debugs it natively. Ship Streamable HTTP only when you need remote access, SSO, or multi-user deployment.

Setting Up Your Environment

TypeScript Setup

# Create and initialize project
mkdir my-mcp-server && cd my-mcp-server
npm init -y

# Install MCP SDK and Zod for input validation
npm install @modelcontextprotocol/sdk zod

# TypeScript tooling
npm install -D typescript tsx @types/node
npx tsc --init

Python Setup

# Create project with uv (recommended) or pip
uv add "mcp[cli]"
# or: pip install "mcp[cli]"

# For the FastMCP high-level API (recommended for new projects)
uv add fastmcp
# or: pip install fastmcp

Project Structure

mcp-server/
├── src/
│   ├── index.ts          # TypeScript entry
│   └── server.py         # Python entry (pick one language)
├── package.json          # TypeScript deps
├── pyproject.toml        # Python deps
└── README.md

Building Your First MCP Server

Every MCP server follows the same pattern: instantiate a server object, register capabilities (tools, resources, prompts), connect a transport, and handle requests.

TypeScript with McpServer (High-Level API)

The McpServer class handles all protocol details automatically. Zod schemas provide input validation:

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: "weather-server",
  version: "1.0.0",
});

// Register a tool with Zod input schema
server.tool(
  "get_forecast",
  "Get weather forecast for a city",
  {
    city: z.string().describe("City name"),
    days: z.number().optional().default(3).describe("Number of days"),
  },
  async ({ city, days }) => {
    const response = await fetch(
      `https://api.weather.gov/points/${encodeURIComponent(city)}`
    );
    const data = await response.json();
    return {
      content: [{ type: "text", text: `Forecast for ${city}: ${data.properties.periods[0].detailedForecast}` }],
    };
  }
);

// Register a resource (URI-addressed data)
server.resource(
  "server-config",
  "config://server",
  async (uri) => ({
    contents: [{
      uri: uri.href,
      mimeType: "application/json",
      text: JSON.stringify({ version: "1.0", env: "production" }),
    }],
  })
);

// Connect stdio transport and start
const transport = new StdioServerTransport();
await server.connect(transport);

Run with: npx tsx src/index.ts

Critical: Never write to stdout outside the SDK. console.log corrupts the JSON-RPC stream. Use console.error for logging — clients forward stderr to their debug logs.

Python with FastMCP

FastMCP uses Python decorators and type hints to auto-generate JSON Schema from function signatures:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("weather-server")

@mcp.tool()
def get_forecast(city: str, days: int = 3) -> str:
    """Get weather forecast for a city."""
    import httpx
    response = httpx.get(f"https://api.weather.gov/points/{city}")
    data = response.json()
    return f"Forecast for {city}: {data['properties']['periods'][0]['detailedForecast']}"

@mcp.resource("config://server")
def get_config() -> str:
    """Server configuration."""
    return '{"version": "1.0", "env": "production"}'

if __name__ == "__main__":
    mcp.run(transport="stdio")

Run with: uv run src/server.py or python src/server.py

FastMCP handles serialization, error formatting, and transport setup automatically. The @mcp.tool() decorator reads the function name, docstring, and type hints to produce the tool definition. For rapid prototyping, this is the fastest path to a working server.

Low-Level API (TypeScript)

If you need fine-grained control over protocol handling, use the lower-level Server class:

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: "weather-server", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [{
    name: "get_forecast",
    description: "Get weather forecast for a city",
    inputSchema: {
      type: "object",
      properties: {
        city: { type: "string", description: "City name" },
      },
      required: ["city"],
    },
  }],
}));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;
  // Handle tool execution
  return { content: [{ type: "text", text: `Result for ${name}` }] };
});

const transport = new StdioServerTransport();
await server.connect(transport);

Streamable HTTP Transport (Remote Servers)

For remote or multi-tenant deployments, use Streamable HTTP. A single endpoint handles all requests, with optional SSE streaming for server-initiated messages.

TypeScript with Express

import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { randomUUID } from "node:crypto";
import { z } from "zod";

const app = express();
app.use(express.json());

const server = new McpServer({ name: "remote-server", version: "1.0.0" });

server.tool("get_forecast", "Weather forecast",
  { city: z.string() },
  async ({ city }) => ({ content: [{ type: "text", text: `Weather in ${city}` }] })
);

const transport = new StreamableHTTPServerTransport({
  sessionIdGenerator: () => randomUUID(),
  allowedHosts: ["localhost:3000", "mcp.example.com"],
  allowedOrigins: ["https://claude.ai", "https://cursor.sh"],
  enableDnsRebindingProtection: true,
});

await server.connect(transport);

app.all("/mcp", (req, res) => transport.handleRequest(req, res, req.body));
app.listen(3000, "127.0.0.1");

Three non-negotiable defaults: validate the Origin header on every request, bind to 127.0.0.1 for local servers, and authenticate remote servers (OAuth 2.1 with PKCE via the SDK’s mcpAuthRouter, or a signed JWT for internal use).

Python FastMCP with Streamable HTTP

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("remote-server")

@mcp.tool()
def get_forecast(city: str) -> str:
    return f"Weather in {city}"

if __name__ == "__main__":
    mcp.run(transport="streamable-http")

For serverless-friendly deployments, pass stateless_http=True and json_response=True.

Connecting to MCP Clients

Claude Desktop

Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):

{
  "mcpServers": {
    "weather-server": {
      "command": "node",
      "args": ["/absolute/path/to/dist/index.js"]
    }
  }
}

For Python servers deployed via pip:

{
  "mcpServers": {
    "weather-server": {
      "command": "uvx",
      "args": ["weather-server@latest"]
    }
  }
}

Cursor

Create .cursor/mcp.json in your project:

{
  "mcpServers": {
    "weather-server": {
      "command": "node",
      "args": ["/absolute/path/to/dist/index.js"]
    }
  }
}

VS Code

Create .vscode/mcp.json in your project:

{
  "servers": {
    "weather-server": {
      "type": "stdio",
      "command": "node",
      "args": ["/absolute/path/to/dist/index.js"]
    }
  }
}

Claude Code (CLI)

claude mcp add weather-server -- node /absolute/path/to/dist/index.js

# For remote servers
claude mcp add weather-server --transport http --url https://mcp.example.com/mcp

Always use absolute paths for stdio servers — clients spawn subprocesses from their own working directory. Quit and relaunch the client after editing config.

Security Best Practices

Security is the hardest part of MCP server development. Researchers disclosed 30+ MCP-related CVEs in the first four months of 2026, including a CVSS 9.6 command-injection RCE in mcp-remote (downloaded ~500K times) and a zero-interaction prompt injection in Windsurf (CVE-2026-30615).

Threat Categories and Defenses

Tool Poisoning — A malicious server embeds hidden instructions in a tool’s description or parameter names. The model reads those as user intent. Defense: only install servers from trusted sources, treat the annotations field as untrusted per spec.

Command Injection — Interpolating client-supplied strings into shell commands or SQL. The mcp-remote RCE happened exactly this way. Defense: parameterize every shell and DB call, validate with Zod or Pydantic before dispatch, prefer execFile over shell invocations.

DNS Rebinding — An attacker’s page tricks a browser into hitting http://localhost:3000/mcp. Defense: the Origin and Host allowlist shown in the Streamable HTTP example plus localhost-only binding.

Over-Scoped Filesystem Access — The server runs as your user. Defense: accept an explicit roots allowlist; never read .env*, ~/.ssh/**, ~/.aws/** without a whitelist.

Confused-Deputy / Cross-Server Exfiltration — A poisoned server instructs the model to read data from another server and exfiltrate it. Defense: fine-grained tool approvals on the host side and least-privilege scopes per server.

OWASP Guidelines (February 2026)

Practice Implementation
Input validation Validate all tool inputs against strict Zod/Pydantic schemas
Least privilege Scoped API keys, read-only DB connections, restricted filesystem paths
User consent Every tool invocation requires explicit user approval
Rate limiting Cap tool calls per session to prevent runaway AI loops
Transport security TLS 1.3 for remote servers; sandboxed subprocess for stdio
Audit logging Log every invocation with timestamps, params, and results

OAuth 2.1 (Spec 2025-06-18)

The June 2025 spec changed how authentication works. MCP servers are now OAuth Resource Servers, not Authorization Servers. They consume access tokens issued by your existing identity provider (Auth0, Okta, Keycloak) rather than issuing their own tokens. This aligns MCP with established enterprise auth patterns.

Testing with MCP Inspector

MCP Inspector is the official debugger — a React UI plus a proxy process that speaks any transport.

# Inspect a TypeScript server in development
npx @modelcontextprotocol/inspector node dist/index.js

# Inspect a Python server
npx @modelcontextprotocol/inspector uv run server.py

# Inspect a published npm server
npx @modelcontextprotocol/inspector npx @modelcontextprotocol/server-filesystem ~/Documents

# Inspect a running Streamable HTTP server
npx @modelcontextprotocol/inspector --transport http --url http://localhost:3000/mcp

The UI opens at http://localhost:6274 with tabs for Tools, Resources, Prompts, and a Notifications pane showing all JSON-RPC traffic — the fastest way to verify schemas and catch capability-negotiation errors.

Best Practices

Tool Design

  • Scope: One MCP server per coherent product area — a jira-server, postgres-server, stripe-server — not a “platform-server” with 80 tools.
  • Count limit: Agent quality degrades sharply past 30–40 tools per context (Anthropic internal evaluations). Keep each server focused.
  • Names: Use verbs for tool names (get_forecast, create_ticket), keep descriptions under 1K tokens, prefer one multi-arg tool over many near-duplicates.
  • Errors: Always return structured responses with { content: [...], isError: true } on failure.

Development Workflow

  1. Start with stdio — zero network config, Inspector works natively
  2. Use the high-level APIMcpServer (TypeScript) or FastMCP (Python) for all new projects
  3. Never write to stdout — corrupts JSON-RPC for stdio transport
  4. Validate inputs — Zod for TypeScript, Pydantic type hints for Python
  5. Ship Streamable HTTP only when needed — remote access, SSO, multi-user

Common Pitfalls

  • Building a 50-tool monolith (split into focused servers)
  • Using HTTP+SSE transport (deprecated; use Streamable HTTP)
  • Writing to stdout (console.log instead of console.error)
  • Relative paths in client configs (must be absolute)
  • Not restarting the client after config changes (close the window and relaunch)

Real-World MCP Use Cases

Use Case MCP Server Value
Database Assistant Read-only SQL query tool + schema resource Query production data in natural language
CI/CD Integration GitHub Actions wrapper tools Trigger builds, check status from IDE
Documentation Search Internal docs as resources AI answers from your actual docs
Customer Support CRM + ticketing system connectors AI-powered context from Salesforce, Zendesk
Infrastructure Ops AWS/GCP API wrappers Manage cloud resources through natural language
Code Review Linting + testing + static analysis tools Automated code reviews with real tool output

The common thread: MCP servers turn existing APIs into AI-accessible capabilities without modifying the underlying systems. Your database does not need to know about MCP. The server sits in between and translates.

Spec Timeline and Ecosystem

Date Milestone
Nov 2024 Anthropic releases MCP as open standard
Mar 2025 Streamable HTTP replaces SSE transport
Jun 2025 OAuth 2.1, elicitation, structured output
Nov 2025 Latest stable spec (2025-11-25) with refinements
Dec 2025 Donated to Linux Foundation’s Agentic AI Foundation
Q1 2026 TypeScript SDK v2 stable expected

The Linux Foundation governance is the most consequential 2026 development for MCP. Vendor-neutral governance converts a de facto standard into an actual one — no single company controls the protocol’s evolution.

Conclusion

MCP has become the USB-C for AI — a universal connector that replaces the mess of custom integrations. With 150M+ SDK downloads, 13K+ servers, support from every major AI platform, and neutral governance under the Linux Foundation, it is the standard for connecting AI to tools.

Build focused servers (one per product area, under 30 tools), start with stdio, use the high-level API, validate every input, and ship Streamable HTTP only when you need remote access. Following these patterns will produce servers that work immediately in Claude, Cursor, VS Code, and every other MCP-compatible client.

Resources

Comments

👍 Was this article helpful?