Building Your First MCP Server: A Step-by-Step Tutorial
The Model Context Protocol (MCP) has gone from an Anthropic side-project announced in late 2024 to the de-facto plumbing for tool-using agents in eighteen months. OpenAI, Google, and most major IDE vendors now speak it natively, and the official spec moved through several revisions in 2025, with a 2025-11-25 specification landing the streamable-HTTP transport. If you have not built one yet, the gap between "I read the docs" and "I have a server Claude Desktop is calling" is smaller than it looks.
This tutorial walks the path: protocol basics, the three primitives, a minimal Python and TypeScript skeleton, and how to debug the thing without an LLM in the loop.
What MCP actually is
At the wire level, MCP is JSON-RPC 2.0. Messages are request/response or notification frames over either standard input/output (for locally-spawned servers) or HTTP with Server-Sent Events / streamable HTTP (for remote servers). The June 2025 spec adopted OAuth 2.1 for remote authentication, and the November 2025 spec replaced legacy SSE with streamable HTTP as the canonical remote transport.
A server exposes three primitives:
search_database, send_email)file://, db://table/users)Clients and servers run a capability-negotiation handshake on connect to discover what each side supports. Once established, the model can list tools, call them, and receive structured results.
Pick a transport
.well-known endpoints. [Inference] Pick stdio first; you can always add a remote transport later.Minimal Python server
The official mcp Python SDK takes a few lines:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("weather-server")
@mcp.tool()
def get_weather(city: str) -> str:
"""Return current weather for a city."""
# call your real API here
return f"Sunny, 72F in {city}"
if __name__ == "__main__":
mcp.run() # defaults to stdioThe FastMCP decorator harvests the function signature and docstring into the JSON Schema the protocol exposes. Type hints become the parameter schema; the docstring becomes the tool description the model sees.
Minimal TypeScript server
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" });
server.registerTool(
"get_weather",
{
title: "Get weather",
description: "Return current weather for a city.",
inputSchema: { city: z.string() },
},
async ({ city }) => ({
content: [{ type: "text", text: `Sunny, 72F in ${city}` }],
}),
);
await server.connect(new StdioServerTransport());Both SDKs handle the JSON-RPC framing, capability negotiation, and error envelope for you.
Testing without an LLM
This is the step most tutorials skip. The official MCP Inspector is a UI that lists tools, lets you invoke them with arbitrary arguments, and dumps the raw JSON-RPC traffic. Run it against your server:
npx @modelcontextprotocol/inspector python my_server.pyYou get a browser tab where you can see the tools/list response, hand-craft a tools/call, and inspect what came back. Debug your schema and error shapes here before plugging into a model — every minute spent in Inspector saves ten in Claude Desktop.
Wire it up to Claude Desktop
Edit claude_desktop_config.json (location varies by OS):
{
"mcpServers": {
"weather": {
"command": "python",
"args": ["/absolute/path/to/my_server.py"]
}
}
}Restart Claude Desktop. The hammer icon should show your tool. Ask "what's the weather in Paris" and watch your stdout — you'll see the call land.
What goes wrong
A few sharp edges from real builds:
isError: true content block with the validation message, not a raw exceptiondb://, file://, notion://) and document it; clients cache resource listsWhen to build vs. install
Before writing one, check the official servers repo and the third-party catalogs. There are already MCP servers for Postgres, GitHub, Slack, Notion, Filesystem, and dozens more. Build your own when you're exposing a proprietary system or a workflow no off-the-shelf server covers — not because nobody has done filesystem access yet.
