How to build your first MCP server, step by step (Python and TypeScript)
Build a working Model Context Protocol server with tools and resources, connect it to a real client, and understand what actually happens on the wire.
The Model Context Protocol (MCP) is the USB port of agentic AI: one connector standard that lets any model plug into your tools, files, and data. This tutorial builds the smallest MCP server worth building — one tool, one resource — and, more importantly, shows you how to verify it works before you point a model at it.
Step 1 — Understand the three primitives
Before writing code, know what you’re exposing. An MCP server offers three things to a client:
| Primitive | Who triggers it | Use it for |
|---|---|---|
| Tool | The model decides | Actions and computations (“look up an order”) |
| Resource | The application attaches it | Read-only context (“the config file”) |
| Prompt | The user picks it | Reusable interaction templates |
Most first servers only need tools. We’ll add a resource so you see both.
One thing changed in 2026. The current MCP spec (revision 2026-07-28) is stateless: there is no opening handshake any more, and every request carries its own protocol version and client details. An open connection is not a session, so a server must not remember things between calls. For a first server this changes nothing you write. The current SDKs handle the new protocol for you, and they still answer older clients that use the 2025-era handshake. The practical rule: if a tool needs something from an earlier call, pass it in as an argument. Don’t keep it in a global variable.
Step 2 — Python track: scaffold with the official SDK
mkdir hello-mcp && cd hello-mcp
python3 -m venv .venv && source .venv/bin/activate
pip install "mcp[cli]"
This installs the official Python SDK, version 2.x. Create server.py:
from mcp.server import MCPServer
mcp = MCPServer("hello-mcp")
@mcp.tool()
def order_status(order_id: str) -> str:
"""Look up the shipping status of an order by its ID."""
# Stand-in for a real database call.
fake_db = {"1001": "shipped", "1002": "processing"}
return fake_db.get(order_id, "not found")
@mcp.resource("config://policies/returns")
def returns_policy() -> str:
"""The current returns policy document."""
return "Returns accepted within 30 days with receipt."
if __name__ == "__main__":
mcp.run() # stdio transport by default
That’s a complete server. The docstrings matter: they become the tool
descriptions the model reads when deciding what to call. The type hints
matter too: order_id: str becomes the tool’s input schema.
Following an older tutorial that uses
FastMCP? In SDK 1.x the class wasfrom mcp.server.fastmcp import FastMCP. SDK 2.x renamed it toMCPServer, and the old import now fails withModuleNotFoundError. The decorators did not change, so most 1.x examples work once you fix the import. If you need 1.x on purpose (some agent frameworks still pin it), install"mcp[cli]<2"and keep the old import. See migrating an MCP server to the 2026 spec.
Step 3 — Verify with the MCP Inspector
Never debug a server through a model — use the Inspector first:
mcp dev server.py
This opens a browser UI (the Inspector needs Node.js 22.19 or newer)
where you can list tools, call order_status with {"order_id": "1001"},
read the config://policies/returns resource, and see the raw JSON-RPC
request and response. If it works here, it will work in any client.
The Inspector’s Protocol Era setting starts on legacy, which means the
old 2025 handshake. Your server speaks both eras. Switch the setting to
auto if you want to see the 2026-07-28 traffic.
Step 4 — TypeScript track: the same server in Node
mkdir hello-mcp-ts && cd hello-mcp-ts
npm init -y && npm install @modelcontextprotocol/server zod
The TypeScript SDK v2 is split into packages, and @modelcontextprotocol/server
is the one for servers. (The older single package, @modelcontextprotocol/sdk,
is v1. It still installs and works, but it is the legacy line.)
Create server.mjs:
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { z } from 'zod';
function buildServer() {
const server = new McpServer({ name: 'hello-mcp', version: '1.0.0' });
server.registerTool(
'order_status',
{
description: 'Look up the shipping status of an order by its ID.',
inputSchema: z.object({ order_id: z.string() }),
},
async ({ order_id }) => {
const fakeDb = { 1001: 'shipped', 1002: 'processing' };
return { content: [{ type: 'text', text: fakeDb[order_id] ?? 'not found' }] };
}
);
server.registerResource(
'returns-policy',
'config://policies/returns',
{ description: 'The current returns policy document.', mimeType: 'text/plain' },
async (uri) => ({
contents: [{ uri: uri.href, text: 'Returns accepted within 30 days with receipt.' }],
})
);
return server;
}
// serveStdio answers both the 2026-07-28 protocol and the older
// initialize handshake, building one server per connection.
serveStdio(buildServer);
Two differences from the Python version. First, you register tools and
resources with registerTool and registerResource, each taking a config
object. Second, you hand serveStdio a function that builds the server
rather than a server object. That factory is what lets one file serve both
protocol eras. If you construct a server and call server.connect(...)
yourself, it keeps speaking the 2025-era protocol.
Verify it the same way: npx @modelcontextprotocol/inspector node server.mjs.
Step 5 — Connect it to a real client
For Claude Code, register the server from your project directory:
claude mcp add hello-mcp -- /full/path/to/hello-mcp/.venv/bin/python /full/path/to/hello-mcp/server.py
# TypeScript track: claude mcp add hello-mcp-ts -- node /full/path/to/hello-mcp-ts/server.mjs
Use the venv’s own python, not a bare python. The client starts your
server as a subprocess and won’t see your activated virtualenv.
Then ask: “What’s the status of order 1001?” — the model will discover
order_status, call it, and answer “shipped.” You built the bridge; the
model found it on its own. That discovery step is the entire point of MCP.
Troubleshooting
Client shows the server as “failed” immediately
Nine times out of ten the command path is wrong. MCP clients launch your
server as a subprocess; use absolute paths (/home/you/hello-mcp/.venv/bin/python)
rather than relying on the client inheriting your shell’s virtualenv.
ModuleNotFoundError: No module named 'mcp.server.fastmcp'
You’re running 1.x code on SDK 2.x, which pip install "mcp[cli]" now
installs. Change the import to from mcp.server import MCPServer and
FastMCP(...) to MCPServer(...). To stay on 1.x, run
pip install "mcp[cli]<2" instead.
TypeScript: every request fails with -32603 Internal server error
serveStdio runs your factory when the first request arrives, so an
exception inside buildServer() turns into a generic Internal server error
and nothing is printed. To see the real error, pass a handler:
serveStdio(buildServer, { onerror: (e) => console.error(e) }). A common
cause is v1 code: TypeError: server.tool is not a function.
server.tool(...) and server.resource(...) were deprecated in
@modelcontextprotocol/sdk 1.x and are gone from @modelcontextprotocol/server
2.x. Use registerTool(name, config, handler) and
registerResource(name, uri, config, handler) as shown in Step 4.
Input should be a valid string / expected string, received number
The tool expects order_id as a string, and the client sent the number
1001. The Inspector CLI does this with --tool-arg order_id=1001; quote
the value as --tool-arg 'order_id="1001"'. In the web UI, enter
"1001" with quotes. The rejection is the schema working: the type hint
is the contract.
mcp: command not found after pip install
You installed mcp without the CLI extra, or your venv isn’t activated.
Run pip install "mcp[cli]" inside the activated venv.
Tools appear but every call returns an error
Print to stderr, never stdout. On stdio transport, stdout carries the
JSON-RPC protocol — a stray print() corrupts the stream. Use
logging (Python) or console.error (Node) for debugging output.
Was this guide useful?
Thanks — noted. It shapes what gets written next.
newsletter
One practical agentic-AI guide in your inbox. No news, no hype.
Tutorials and decision frameworks as they ship. Unsubscribe anytime.