Building your first MCP server is simpler than it looks. At its core, an MCP server is a small program that exposes tools, resources, and prompts through the Model Context Protocol so any compatible AI client — Claude, Cursor, VS Code, or a custom agent — can call them. In this guide, you’ll go from zero to a working server in under an hour using TypeScript or Python, test it with the official MCP Inspector, and connect it to a real client. No prior protocol experience required.
What You’ll Build
By the end of this tutorial you will have a production-style MCP server that exposes a single tool: get_weather. It accepts a city name, fetches current conditions from a weather API, and returns structured JSON that any MCP client can consume. You will then expand it with a resource (read-only data) and a prompt template, then serve it over both stdio and HTTP(S). This pattern covers 90% of real-world MCP projects.
Key Takeaways
- MCP servers are not APIs in the REST sense. They speak a JSON-RPC-like protocol, discover tools/resources/prompts at runtime, and stream messages over stdio or SSE.
- The official SDKs (TypeScript
@modelcontextprotocol/sdkand Pythonmcp) handle protocol framing, capability negotiation, and lifecycle so you only write business logic. - Every tool needs a JSON Schema, an async handler, and a name registered in the server.
- Testing should happen in two layers: the MCP Inspector for protocol correctness, and the MCP Server Tester for end-to-end client simulation.
- Moving to production means adding input validation, structured logging, error boundaries, transport security, and semantic versioning — not rewriting the server.
What You Need Before Starting
You only need a modern Node.js or Python environment. Pick one stack for your first server; both produce identical protocol behavior, so the choice is organizational, not technical. If you already maintain Node services, use TypeScript. If your backend is Python-first, use Python.
Option A: TypeScript Stack
- Node.js 20+ (LTS recommended)
- A package manager: npm, pnpm, or yarn
- The official SDK:
@modelcontextprotocol/sdk - We use SDK 1.x patterns as current in 2026
Create a folder and install dependencies:
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --init --outDir dist --esModuleInterop true --module NodeNext --moduleResolution NodeNext
Option B: Python Stack
- Python 3.10+
- pip or uv
- The official SDK:
mcp(formerlymcp-python-sdk)
Create a project with uv (recommended) or pip:
mkdir my-mcp-server && cd my-mcp-server
uv init --python 3.12
uv add mcp
If you are still deciding whether an MCP server is the right integration shape for your use case, read MCP vs API: When to Use Each before continuing.
MCP Server Anatomy: Tools, Resources, Prompts
An MCP server advertises three kinds of primitives. Think of them as the vocabulary your AI client uses to talk to you.
| Primitive | Purpose | Best For | Client Action |
|---|---|---|---|
| Tool | Perform an action or computation | Weather lookup, database query, file write, sending a message | tools/call |
| Resource | Read-only data exposed by URI | Documents, configuration, status dashboards, product catalogs | resources/read |
| Prompt | Reusable prompt template | Onboarding flows, review checklists, structured generation templates | prompts/get |
A minimal MCP server can expose just one tool. A rich one might expose dozens of tools, dynamic resources with paginated URIs, and versioned prompt templates. Start small and add primitives only when a client actually needs them. This keeps context windows small and discovery fast.
Transport: stdio vs HTTP(S)
MCP supports multiple transports. The two you will use most are:
- stdio — the server reads JSON-RPC messages from stdin and writes responses to stdout. This is the default for local clients like Claude Desktop, Cursor, and VS Code extensions because it isolates the server as a child process.
- HTTP with SSE — the server runs as a remote service. Clients connect over HTTP and receive Server-Sent Events for server-to-client messages. This is better for shared infrastructure or multi-tenant SaaS.
In this guide you will implement both. The business logic stays identical; only the transport wiring changes.
Step 1: Define Your Tool Schema
Every tool has a name, a description, and an input schema. The description is part of the product: it is what the LLM reads to decide whether to call your tool. Write it as an instruction, not a label. Include the expected input format and any constraints.
TypeScript: Schema with Zod
The TypeScript SDK accepts a JSON Schema directly, but most developers prefer deriving it from a Zod object because Zod also provides runtime validation. Install it if you have not already (npm install zod).
// src/weather-tool.ts
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
export const GetWeatherInputSchema = z.object({
city: z.string().min(1).max(100).describe("City name, e.g. 'Berlin'"),
units: z.enum(["metric", "imperial"]).default("metric"),
});
export type GetWeatherInput = z.infer<typeof GetWeatherInputSchema>;
export const WeatherToolSchema = {
name: "get_weather",
description:
"Get the current weather for a city. Use this when the user asks about temperature, rain, or conditions.",
inputSchema: zodToJsonSchema(GetWeatherInputSchema, {
name: "GetWeatherInput",
$refStrategy: "none",
}) as any,
};
Python: Schema with Pydantic
The Python SDK can derive the JSON Schema automatically from a Pydantic model. This is the cleanest pattern for 2026.
# server/weather_tool.py
from pydantic import BaseModel, Field
from typing import Literal
class GetWeatherInput(BaseModel):
city: str = Field(..., min_length=1, max_length=100, description="City name, e.g. 'Berlin'")
units: Literal["metric", "imperial"] = Field(default="metric")
weather_tool = {
"name": "get_weather",
"description": (
"Get the current weather for a city. Use this when the user asks about "
"temperature, rain, or conditions."
),
"inputSchema": GetWeatherInput.model_json_schema(),
}
Notice that the description tells the model when to use the tool. This reduces hallucinated calls and improves routing accuracy. If your descriptions are vague, the client will call the wrong tool or ignore it entirely.
Step 2: Implement the Handler
A handler is an async function that receives validated arguments and returns either a result or a structured error. Keep handlers stateless and side-effect-free where possible; the client may retry a call.
TypeScript Handler
// src/handlers.ts
import { GetWeatherInput } from "./weather-tool.js";
export interface WeatherResult {
city: string;
temperature: number;
units: "metric" | "imperial";
condition: string;
humidity: number;
updated_at: string;
}
export async function getWeather(args: GetWeatherInput): Promise<WeatherResult> {
// In production, call Open-Meteo, OpenWeatherMap, or your own API.
// For the tutorial we simulate a deterministic response.
const tempC = args.city.length + 12; // deterministic fake logic
const temp = args.units === "imperial" ? Math.round(tempC * 9 / 5 + 32) : tempC;
return {
city: args.city,
temperature: temp,
units: args.units,
condition: "partly cloudy",
humidity: 62,
updated_at: new Date().toISOString(),
};
}
Python Handler
# server/handlers.py
from datetime import datetime, timezone
from .weather_tool import GetWeatherInput
from typing import Literal
async def get_weather(args: GetWeatherInput) -> dict:
temp_c = len(args.city) + 12
temp = round(temp_c * 9 / 5 + 32) if args.units == "imperial" else temp_c
return {
"city": args.city,
"temperature": temp,
"units": args.units,
"condition": "partly cloudy",
"humidity": 62,
"updated_at": datetime.now(timezone.utc).isoformat(),
}
Both handlers return plain objects. The SDK serializes them into the MCP content array, which is how text, images, and embedded resources travel back to the client.
Step 3: Register and Serve (stdio + HTTP)
Registration is the bridge between your schema/handler and the protocol. In the TypeScript SDK you create a Server instance and attach a request handler. In Python you use the high-level Server class from mcp.server.
TypeScript: stdio Server
// src/index.ts
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 { WeatherToolSchema } from "./weather-tool.js";
import { getWeather } from "./handlers.js";
const server = new Server(
{ name: "weather-server", version: "0.1.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [WeatherToolSchema],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name !== "get_weather") {
throw new Error(`Unknown tool: ${request.params.name}`);
}
const result = await getWeather(request.params.arguments as any);
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
});
const transport = new StdioServerTransport();
await server.connect(transport);
Python: stdio Server
# server/main.py
import asyncio
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import TextContent
from .weather_tool import weather_tool, GetWeatherInput
from .handlers import get_weather
app = Server("weather-server")
@app.list_tools()
async def list_tools() -> list:
return [weather_tool]
@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list:
if name != "get_weather":
raise ValueError(f"Unknown tool: {name}")
args = GetWeatherInput.model_validate(arguments)
result = await get_weather(args)
return [TextContent(type="text", text=json.dumps(result, indent=2))]
async def main():
async with stdio_server() as (read_stream, write_stream):
await app.run(
read_stream,
write_stream,
app.create_initialization_options(),
)
if __name__ == "__main__":
asyncio.run(main())
Run the stdio server manually to confirm it starts without crashing:
# TypeScript
npx tsx src/index.ts
# Python
python -m server.main
It will appear to hang because it is waiting for JSON-RPC messages on stdin. That is correct. In the next step you will feed it messages with the Inspector.
HTTP/SSE Server
For remote deployments you can swap the transport. The business logic does not change. Below is a minimal Python HTTP example using the SDK’s built-in SSE transport and Starlette. TypeScript has an equivalent SseServerTransport for Express or Fastify.
# server/http_main.py
import asyncio
import json
from starlette.applications import Starlette
from starlette.routing import Route
from mcp.server import Server
from mcp.server.sse import SseServerTransport
from .weather_tool import weather_tool, GetWeatherInput
from .handlers import get_weather
app = Server("weather-server-http")
sse_transport = SseServerTransport("/messages/")
@app.list_tools()
async def list_tools() -> list:
return [weather_tool]
@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list:
from mcp.types import TextContent
if name != "get_weather":
raise ValueError(f"Unknown tool: {name}")
args = GetWeatherInput.model_validate(arguments)
result = await get_weather(args)
return [TextContent(type="text", text=json.dumps(result, indent=2))]
async def handle_sse(request):
async with sse_transport.connect_sse(request.scope, request.receive, request.send) as streams:
await app.run(streams[0], streams[1], app.create_initialization_options())
routes = [
Route("/sse", handle_sse),
Route("/messages/{session_id}", sse_transport.handle_post_message, methods=["POST"]),
]
starlette_app = Starlette(routes=routes)
Run it with uvicorn server.http_main:starlette_app --port 3001, then connect a client to http://localhost:3001/sse.
Step 4: Test with MCP Inspector + MCP Server Tester
Testing is where most first-time builders get stuck. The protocol uses JSON-RPC framing, so a simple curl is not enough. Use the official tools.
Install and Run the MCP Inspector
npx @modelcontextprotocol/inspector node dist/index.js
For Python:
npx @modelcontextprotocol/inspector python -m server.main
The Inspector opens a web UI, connects to your server over stdio, and lets you list tools, inspect schemas, invoke handlers, and view raw JSON-RPC traffic. Use it to verify:
- The server initializes without protocol errors.
tools/listreturns exactly the tools you registered.tools/callwith valid arguments returns the expected JSON.- Invalid arguments produce clean error content, not crashes.
End-to-End Client Simulation with MCP Server Tester
The Inspector validates protocol behavior, but it does not simulate a real LLM client deciding whether to call your tool. That is what the MCP Server Tester is for. Paste your server command, and the tester acts as an MCP host: it sends natural-language prompts, observes which tools the model selects, validates the arguments, and surfaces mismatches between your schema and your descriptions.
| Test Layer | Tool | What It Proves |
|---|---|---|
| Protocol correctness | MCP Inspector | JSON-RPC framing, schema shape, initialization |
| LLM integration | MCP Server Tester | Tool selection, argument binding, end-to-end response quality |
Run both before moving on. A server that passes the Inspector but fails the Server Tester usually has a description or schema problem — the LLM cannot figure out how to use it.
Step 5: Connect to Claude, Cursor, or VS Code
Once the server passes tests, connect it to a client. Each client reads a configuration file that points to the server command.
Claude Desktop Configuration
Edit claude_desktop_config.json on macOS at ~/Library/Application Support/Claude/claude_desktop_config.json, or the equivalent path on Windows/Linux.
{
"mcpServers": {
"weather": {
"command": "node",
"args": ["/absolute/path/to/my-mcp-server/dist/index.js"]
}
}
}
Cursor Configuration
In Cursor, open Settings → MCP, then add a server with the command:
node /absolute/path/to/my-mcp-server/dist/index.js
Cursor will list discovered tools in the chat panel. You can then ask, “What is the weather in Tokyo?” and watch the model call get_weather.
VS Code with an MCP Extension
VS Code extensions that support MCP usually read a .vscode/mcp.json file or the user settings. Check your extension’s documentation, but the shape is similar:
{
"mcpServers": {
"weather": {
"command": "node",
"args": ["${workspaceFolder}/dist/index.js"]
}
}
}
After restarting the client, open the developer logs if available. You should see the initialization handshake, tools/list, and then a tools/call when you ask a weather question.
Common Pitfalls
These are the failures we see most often in new MCP servers. Most take under five minutes to fix once you know what to look for.
Schema Errors
If the client reports “no parameters accepted” or refuses to call the tool, your schema is probably empty, malformed, or using a draft that the client does not understand. Stick to JSON Schema Draft 7 features, avoid nested $ref cycles, and always provide a top-level properties object for tools.
Async Bugs
Handlers must be async in modern SDKs. A synchronous handler may work in older versions but will deadlock or time out in 2026-current SDKs because the transport expects awaited results. Mark every handler with async and never block the event loop with long synchronous calls.
Context Bloat
Returning huge JSON objects from tools pollutes the conversation context. Keep tool results concise. If you need to return many records, expose a resource URI instead and let the client read it only when needed. Resources are cached by the client and fetched on demand.
Description Mistakes
Vague descriptions like “weather tool” cause the model to ignore the tool or call it at the wrong time. Include the trigger condition, input shape, and output shape in the description.
Missing Error Boundaries
An unhandled exception in a handler can crash the stdio transport and leave the client with a broken pipe. Wrap external API calls in try/catch and return errors as structured MCP content with isError: true.
// TypeScript error boundary example
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
// ... handler logic
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [{ type: "text", text: message }],
isError: true,
};
}
});
From Prototype to Production
A tutorial server is not the same as a production server. Before you ship, harden five areas: security, logging, versioning, health, and packaging.
Security
- Never trust tool arguments. Validate and sanitize every input.
- If the tool calls an external API, store keys in environment variables, never in source code.
- For HTTP transport, require authentication. The MCP spec does not define auth, so use a reverse proxy with OAuth2, API keys, or mTLS in front of your SSE endpoint.
- Limit file-system or network access with a sandbox if the server runs arbitrary code.
Logging
stdio servers cannot log to stdout because stdout is the transport. Log to stderr or to a file. Use structured JSON logging so log aggregation tools can parse it.
// TypeScript: log to stderr
console.error(JSON.stringify({ level: "info", event: "tool_called", tool: request.params.name }));
Versioning
MCP clients can request a specific server version or check capabilities. Bump the version in your server info on every meaningful change, and maintain a changelog. If you rename a tool, keep an alias for one version to avoid breaking existing clients.
Health Checks
For HTTP servers, expose a lightweight /health endpoint outside the MCP transport. Load balancers and orchestrators can use it without interfering with MCP sessions.
Packaging and Distribution
Make installation one command. For TypeScript, publish a compiled dist/index.js with a shebang or an npm bin entry. For Python, publish a package with a console script in pyproject.toml. Provide sample client configuration snippets for Claude, Cursor, and VS Code in your README.
For inspiration, see our curated list of best MCP servers for developers — it includes real servers with clean packaging and documentation patterns.
Expanding Your Server: Resources and Prompts
Once your first tool works, add a resource and a prompt to round out the server.
Adding a Resource
A resource is read-only data identified by a URI. It is perfect for documents, configuration, or reference data that the model should read on demand rather than receive on every tool call.
# Python resource example
@app.list_resources()
async def list_resources() -> list:
return [{
"uri": "weather://status",
"name": "Weather Service Status",
"mimeType": "application/json",
}]
@app.read_resource()
async def read_resource(uri: str) -> str:
if uri == "weather://status":
return json.dumps({"service": "weather", "healthy": True})
raise ValueError(f"Unknown resource: {uri}")
Adding a Prompt Template
A prompt exposes a reusable template. Clients call prompts/get with arguments and receive a formatted message list.
# Python prompt example
@app.list_prompts()
async def list_prompts() -> list:
return [{
"name": "weather_forecast_prompt",
"description": "Generate a friendly weather forecast summary.",
"arguments": [
{"name": "city", "description": "City name", "required": True},
{"name": "temperature", "description": "Temperature value", "required": True},
],
}]
@app.get_prompt()
async def get_prompt(name: str, arguments: dict | None) -> dict:
if name != "weather_forecast_prompt":
raise ValueError(f"Unknown prompt: {name}")
return {
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": f"Write a friendly 2-sentence forecast for {arguments['city']} at {arguments['temperature']} degrees.",
},
}
]
}
FAQ
Do I need to write my own JSON-RPC parser?
No. The official TypeScript and Python SDKs handle framing, message parsing, request/response correlation, and capability negotiation.
Can I build an MCP server without writing code?
Fully no-code MCP servers are limited, but low-code options exist. Some platforms let you configure a tool endpoint and generate the server wrapper automatically. If you have an existing REST API, you can use a scaffold generator. Try Prompt to Agent Scaffold to turn a plain-English tool description into starter MCP server code.
Which transport should I use?
Use stdio for local desktop clients (Claude Desktop, Cursor, VS Code). Use HTTP/SSE for remote or multi-user deployments.
Can one server expose both stdio and HTTP?
Yes, but usually not in the same process. Use an environment variable or CLI flag to select the transport at startup, or build a tiny wrapper that imports the same handlers.
How do I version tool schemas?
Bump your server version and, when possible, keep old tool names working as aliases for at least one release. Document breaking changes in your README and release notes.
What is the difference between a tool and a resource?
A tool performs an action and may have side effects. A resource is read-only data exposed by URI. If in doubt, ask: “Would a REST API use GET or POST?” GET-like data should usually be a resource; POST-like actions should be tools.
How do I debug a server that the client cannot discover?
First, run it with the MCP Inspector. If that works, check the client configuration path, command, and absolute paths. Then use the MCP Server Tester to verify end-to-end behavior. Finally, enable stderr logging in your server and watch the client logs for initialization errors.
Can I return images or files from a tool?
Yes. MCP supports image and embedded resource content types. Return a content item with type: "image" and a base64 data field, or type: "resource" with a URI.
Is MCP only for Claude?
No. MCP is an open protocol. Cursor, VS Code extensions, Windsurf, and many agent frameworks support it. As long as your server follows the spec, any compatible client can use it.
Next Steps
You now have a complete MCP server pattern: schema, handler, registration, stdio and HTTP transport, testing, and client connection. The fastest way to solidify it is to build your own tool. Start with something small — a calculator, a todo list, or a search wrapper — run it through the Inspector, then test it with the MCP Server Tester.
If you want to compare your design against established servers, read best MCP servers for developers and how to test an MCP server. And if you are choosing between MCP and a traditional API for your next integration, our MCP vs API guide will help you decide.
Ready to ship? Validate your server against a real LLM client at /tools/mcp-server-tester before you publish.