How to Build an MCP Server in Python and TypeScript (2026)
This guide is for engineers who want to expose an internal API, a runbook store or a CLI to AI agents through the Model Context Protocol, and want to do it on the official SDKs rather than a blog post from 2025. At the end you will have the same small server written twice, in Python and TypeScript, with typed tools, a resource template and a prompt; a unit test; a smoke test through MCP Inspector; working configs for Claude Desktop, Claude Code and VS Code; a stateless Streamable HTTP mode; and a package you can publish. Everything below was run on 20 October 2026 with Python 3.14, mcp 2.3.0, @modelcontextprotocol/server 2.3.1, Node 20.17 for the server and Node 24.20 for the Inspector, and the outputs shown are copied from my terminal. You need Python 3.10 or later (the SDK's floor), Node 20 or later, and ideally uv. If MCP itself is new to you, read my plain-English explainer of MCP first; this post assumes you know what a host, a client and a server are.
Step 1: Pick the SDK and pin the version
The protocol had a breaking revision on 28 July 2026, and the SDKs split along it. The 2026-07-28 release post removed the initialize handshake and session IDs; every request now carries its protocol version and capabilities in _meta. Both flagship SDKs moved to a 2.x line to implement it, and both renamed things. The table is what the package registries said this morning.
| Package | Latest | Speaks | Notes | Source |
|---|---|---|---|---|
mcp (Python) |
2.3.0, 2 Oct 2026 | 2026-07-28, falls back to 2025-11-25 | FastMCP renamed MCPServer; Python 3.10+ |
PyPI |
@modelcontextprotocol/server (TS v2) |
2.3.1, 5 Oct 2026 | 2026-07-28 | Split packages; Node 20+; Zod 4 | npm |
@modelcontextprotocol/sdk (TS v1) |
1.32.1, 5 Oct 2026 | 2025-11-25 | The old monolith, still the most downloaded | npm |
fastmcp (Python, PrefectHQ) |
4.1.1, 18 Oct 2026 | own release line | Independent project, not the SDK's class | PyPI |
@modelcontextprotocol/inspector |
2.11.0, 14 Oct 2026 | both eras | Needs Node 22.19+ | GitHub |
Two warnings that will save you an afternoon. First, almost every tutorial on the web still imports from mcp.server.fastmcp import FastMCP; on mcp 2.x that import raises a ModuleNotFoundError whose message points to the migration guide. Second, the standalone fastmcp package is a different project with its own API, so "FastMCP-style decorators" now means either the SDK's MCPServer or PrefectHQ's library, and you should pick one deliberately. This guide uses the official SDKs. Pin exact versions in both languages: the v1 to v2 jump showed how much moves between majors.
Step 2: Write the Python server
Create a project and add the SDK with its CLI extra:
uv init ops-notes && cd ops-notes
uv add "mcp[cli]==2.3.0"
uv add --dev "pytest==8.4.2"
The server below exposes two tools, one resource template and one prompt. Type hints become the JSON Schema the model sees, a Pydantic return type becomes outputSchema plus structuredContent, and the docstring becomes the description, so write docstrings for a model that has never seen your codebase.
# server.py: a small MCP server on the official Python SDK (mcp==2.3.0).
import sys
from typing import Literal
from pydantic import BaseModel, Field
from mcp.server.mcpserver import MCPServer, Context
from mcp.types import ToolAnnotations
mcp = MCPServer(
name="ops-notes",
version="0.1.0",
instructions="Read-only helpers for looking up service runbooks and HTTP status codes.",
)
RUNBOOKS = {
"payments": "1. Check the payments-api dashboard. 2. Roll back the last deploy if error rate > 2%.",
"search": "1. Check index lag. 2. Restart the indexer deployment.",
}
class StatusInfo(BaseModel):
code: int
meaning: str
retryable: bool = Field(description="Whether a client should retry this status")
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False))
def http_status(code: int) -> StatusInfo:
"""Explain an HTTP status code and whether it is safe to retry."""
table = {
429: ("Too Many Requests", True),
500: ("Internal Server Error", True),
502: ("Bad Gateway", True),
503: ("Service Unavailable", True),
504: ("Gateway Timeout", True),
404: ("Not Found", False),
}
meaning, retryable = table.get(code, ("Unknown to this tool", False))
return StatusInfo(code=code, meaning=meaning, retryable=retryable)
@mcp.tool()
async def list_services(ctx: Context, env: Literal["prod", "staging"] = "prod") -> list[str]:
"""List services that have a runbook."""
await ctx.info(f"listing runbooks for {env}") # goes to the client log, never to stdout
return sorted(RUNBOOKS)
@mcp.resource("runbook://{service}", mime_type="text/markdown")
def runbook(service: str) -> str:
"""The on-call runbook for one service."""
return RUNBOOKS.get(service, f"No runbook for {service}.")
@mcp.prompt()
def triage(service: str, symptom: str) -> str:
"""A prompt template for triaging an incident."""
return f"Service {service} shows: {symptom}. Read runbook://{service} first, then propose next steps."
if __name__ == "__main__":
# stdio for local clients (Claude Desktop, Claude Code, VS Code); pass "http" for Streamable HTTP.
if len(sys.argv) > 1 and sys.argv[1] == "http":
mcp.run(transport="streamable-http", host="127.0.0.1", port=8000, stateless_http=True, json_response=True)
else:
mcp.run()
A few design choices worth copying. The readOnlyHint annotation lets clients skip a confirmation prompt for harmless tools; it is a hint to the client, not a security control, so never rely on it to stop a destructive call. The Literal type produces an enum in the schema, which models follow far more reliably than prose saying "must be prod or staging". And the {service} placeholder turns the resource into a template, which clients list under resources/templates/list.
Step 3: Test it in-process before any client touches it
The 2.x SDK's Client accepts an MCPServer instance directly and connects in memory, so you can unit-test tools without spawning anything:
# test_server.py: run with uv run pytest -q
import pytest
from mcp import Client
from server import mcp
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.anyio
async def test_tools_and_resources():
async with Client(mcp) as client:
tools = await client.list_tools()
assert {t.name for t in tools.tools} == {"http_status", "list_services"}
result = await client.call_tool("http_status", {"code": 503})
assert result.structured_content["retryable"] is True
res = await client.read_resource("runbook://payments")
assert "Roll back" in res.contents[0].text
$ uv run pytest -q
. [100%]
1 passed in 0.70s
Then test the real stdio path, which is what Claude Desktop will use. The same Client takes StdioServerParameters; my run printed the negotiated version and the generated schemas:
protocol: 2026-07-28
http_status {"type": "object", "properties": {"code": {"title": "Code", "type": "integer"}}, "required": ["code"], "title": "http_statusArguments"}
list_services {"type": "object", "properties": {"env": {"default": "prod", "enum": ["prod", "staging"], "title": "Env", "type": "string"}}, "title": "list_servicesArguments"}
{"code": 429, "meaning": "Too Many Requests", "retryable": true}
Bad input does not crash the server. Calling http_status with {"code": "abc"} returned a result with isError: true and the Pydantic validation message, which is what you want: the model sees the error and can retry with a corrected argument.
Step 4: Smoke-test with MCP Inspector
The Inspector is the official debugging client, with a web UI, a TUI and a CLI. Version 2 declares Node 22.19 or later in its engines field, so I ran it under Node 24.20 while the servers stayed on Node 20. The CLI is the useful part for CI because it prints JSON and returns a stable exit code. Pin the version, as its own smoke-testing guide recommends, because a bare npx resolves whatever is newest.
# web UI (opens a browser)
npx -y @modelcontextprotocol/inspector@2.11.0
# CLI: connect-only probe, then list and call tools
npx -y @modelcontextprotocol/inspector@2.11.0 --cli uv run server.py --method initialize
npx -y @modelcontextprotocol/inspector@2.11.0 --cli uv run server.py --method tools/list --format json
npx -y @modelcontextprotocol/inspector@2.11.0 --cli uv run server.py \
--method tools/call --tool-name http_status --tool-arg code=502 --format json
Against the TypeScript build from the next step, the call printed:
{"result":{"content":[{"type":"text","text":"{\"code\":502,\"meaning\":\"Bad Gateway\",\"retryable\":true}"}],"structuredContent":{"code":502,"meaning":"Bad Gateway","retryable":true}}}
One thing I noticed: Inspector 2.11.0's --method initialize probe reported protocolVersion: 2025-11-25 against my Python server, so by default it took the handshake path. That is fine for testing tool behaviour, and it confirms the server serves both protocol eras from one binary, but do not read the Inspector's output as proof of which version Claude or VS Code will negotiate.
Step 5: Write the same server in TypeScript
The v2 TypeScript SDK is split into packages: @modelcontextprotocol/server for the server API, @modelcontextprotocol/node (or the Express, Fastify and Hono adapters) for HTTP. Schemas are Zod 4, imported as zod/v4.
mkdir ops-notes-ts && cd ops-notes-ts
npm init -y && npm pkg set type=module
npm install @modelcontextprotocol/server@2.3.1 @modelcontextprotocol/node@2.1.1 zod@4.6.7
npm install -D typescript@7.0.2 @types/node@26.6.5
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "build",
"rootDir": "src",
"strict": true,
"types": ["node"],
"skipLibCheck": true
},
"include": ["src"]
}
The "types": ["node"] line matters: the package README notes that TypeScript 6 and later no longer auto-include @types/*. Put the server definition in a factory so the stdio and HTTP entry points share it:
// src/tools.ts: the ops-notes server on @modelcontextprotocol/server 2.3.1
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const RUNBOOKS: Record<string, string> = {
payments: '1. Check the payments-api dashboard. 2. Roll back the last deploy if error rate > 2%.',
search: '1. Check index lag. 2. Restart the indexer deployment.',
};
const RETRYABLE: Record<number, string> = {
429: 'Too Many Requests', 500: 'Internal Server Error', 502: 'Bad Gateway',
503: 'Service Unavailable', 504: 'Gateway Timeout',
};
export function buildServer(): McpServer {
const server = new McpServer({ name: 'ops-notes', version: '0.1.0' });
server.registerTool(
'http_status',
{
description: 'Explain an HTTP status code and whether it is safe to retry.',
inputSchema: z.object({ code: z.number().int().min(100).max(599) }),
outputSchema: z.object({ code: z.number(), meaning: z.string(), retryable: z.boolean() }),
annotations: { readOnlyHint: true, openWorldHint: false },
},
async ({ code }) => {
const output = { code, meaning: RETRYABLE[code] ?? (code === 404 ? 'Not Found' : 'Unknown to this tool'), retryable: code in RETRYABLE };
return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output };
},
);
server.registerResource(
'runbook',
new ResourceTemplate('runbook://{service}', { list: undefined }),
{ description: 'The on-call runbook for one service', mimeType: 'text/markdown' },
async (uri, { service }) => ({
contents: [{ uri: uri.href, text: RUNBOOKS[String(service)] ?? `No runbook for ${service}.` }],
}),
);
server.registerPrompt(
'triage',
{ description: 'Triage an incident', argsSchema: z.object({ service: z.string(), symptom: z.string() }) },
({ service, symptom }) => ({
messages: [{ role: 'user' as const, content: { type: 'text' as const,
text: `Service ${service} shows: ${symptom}. Read runbook://${service} first, then propose next steps.` } }],
}),
);
return server;
}
// src/stdio.ts: entry point for local clients that spawn the server as a child process
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { buildServer } from './tools.js';
console.error('ops-notes starting on stdio'); // stderr only: stdout is the JSON-RPC channel
serveStdio(buildServer);
Build with npx tsc and run node build/stdio.js. Driving it with the Python client from Step 3 gave protocol: 2026-07-28, the Zod-generated schema with "minimum": 100, "maximum": 599 and a 2020-12 $schema, and the same structured result. The SDK validates structuredContent against outputSchema before sending, and a string where a number belongs came back as isError: true with Input validation error: Invalid arguments for tool http_status.
Step 6: Connect it to Claude Desktop, Claude Code and VS Code
Claude Desktop reads ~/Library/Application Support/Claude/claude_desktop_config.json on macOS and %AppData%\Claude\claude_desktop_config.json on Windows, per the official build-a-server tutorial. Use absolute paths, including to uv if it is not on the GUI app's PATH (which uv), then fully quit and reopen the app.
{
"mcpServers": {
"ops-notes": {
"command": "/Users/you/.local/bin/uv",
"args": ["--directory", "/Users/you/src/ops-notes", "run", "server.py"]
},
"ops-notes-ts": {
"command": "node",
"args": ["/Users/you/src/ops-notes-ts/build/stdio.js"]
}
}
}
Claude Code has a CLI for the same thing. Everything after -- is the server command, untouched; --scope project writes a .mcp.json you can commit, and Claude Code asks each teammate to approve project-scoped servers before first use (docs).
claude mcp add --transport stdio --scope project ops-notes -- uv --directory /Users/you/src/ops-notes run server.py
claude mcp add --transport http ops-notes-http http://127.0.0.1:8000/mcp
claude mcp list # shows a health status per server
# inside a session: /mcp
VS Code uses a top-level servers key, not mcpServers, so do not paste the Claude file. The dev.watch field restarts the server when files change (reference):
{
"servers": {
"ops-notes": {
"type": "stdio",
"command": "uv",
"args": ["--directory", "${workspaceFolder}", "run", "server.py"],
"dev": { "watch": "**/*.py" }
}
}
}
Step 7: Serve it over stateless Streamable HTTP
Stdio is for one user on one machine. For a shared server, use Streamable HTTP, and on the 2026-07-28 protocol make it stateless so any replica behind a plain load balancer can take any request. In Python that is the http branch already in server.py (stateless_http=True, json_response=True). In TypeScript, createMcpHandler runs your factory per request and the Node adapter mounts it, following the SDK's HTTP serving guide:
// src/http.ts: stateless Streamable HTTP on Node, POST http://127.0.0.1:3000/mcp
import { createServer } from 'node:http';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { toNodeHandler, localhostHostValidation, localhostOriginValidation } from '@modelcontextprotocol/node';
import { buildServer } from './tools.js';
const handler = createMcpHandler(buildServer); // factory runs per request: no shared session state
const nodeHandler = toNodeHandler(handler);
const validateHost = localhostHostValidation(); // DNS-rebinding guard; the handler does not check Host itself
const validateOrigin = localhostOriginValidation();
createServer((req, res) => {
if (!validateHost(req, res) || !validateOrigin(req, res)) return;
void nodeHandler(req, res);
}).listen(3000, '127.0.0.1', () => console.error('listening on http://127.0.0.1:3000/mcp'));
process.on('SIGINT', async () => { await handler.close(); process.exit(0); });
You can hit the Python one with curl in the new wire format. Note the per-request _meta envelope; leaving out clientCapabilities got me a -32602 error naming the missing key.
curl -s -X POST http://127.0.0.1:8000/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/call' -H 'Mcp-Name: http_status' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"http_status","arguments":{"code":502},
"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
{"jsonrpc":"2.0","id":2,"result":{"content":[{"text":"{\n \"code\": 502,\n \"meaning\": \"Bad Gateway\",\n \"retryable\": true\n}","type":"text"}],"isError":false,"resultType":"complete","structuredContent":{"code":502,"meaning":"Bad Gateway","retryable":true},"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"ops-notes","version":"0.1.0"}}}}
A request to the TypeScript server with Host: evil.example.com got a 403 from the host guard. Neither example has authentication, so keep them on 127.0.0.1. Before you expose a server to a network, put OAuth in front of it: the remote MCP server OAuth guide covers that, and my MCP ecosystem post has a production-shaped Python server with JWKS token checks and OpenTelemetry spans.
Step 8: Package and publish
For Python, give the module a main() and an entry point so clients can run it with uvx. This pyproject.toml built a wheel that answered a handshake when launched with uvx --from ./dist/ops_notes_mcp-0.1.0-py3-none-any.whl ops-notes-mcp:
[project]
name = "ops-notes-mcp"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["mcp==2.3.0"]
[project.scripts]
ops-notes-mcp = "ops_notes.server:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/ops_notes"] # needed because the package name differs from the project name
For TypeScript, add a bin entry pointing at build/stdio.js with a #!/usr/bin/env node shebang. To list it in the official MCP Registry (still in preview), add "mcpName": "io.github.your-user/ops-notes" to package.json (Python packages put an mcp-name: line in the README instead), then run mcp-publisher init, mcp-publisher login github and mcp-publisher publish. The registry stores metadata only; your package must already be on npm or PyPI.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
No module named 'mcp.server.fastmcp' |
v1 tutorial code on mcp 2.x |
from mcp.server.mcpserver import MCPServer, or pin mcp<2 deliberately |
| Server missing in Claude Desktop, no error | Relative path, uv not on the GUI PATH, or app not fully restarted |
Absolute paths; tail -n 20 -f ~/Library/Logs/Claude/mcp*.log |
| Client reports a JSON parse error or drops the connection | Something wrote to stdout (print, console.log, a chatty library) |
Log to stderr; the Python client I tested skipped the stray line, other hosts may not |
Inspector fails to start or warns about engines |
Inspector 2.x declares Node 22.19+ | Run it under Node 22.19 or newer; the server itself can stay on Node 20 |
-32602 ... missing the required envelope key(s) from curl |
2026-07-28 requests need protocol version and client capabilities in _meta |
Add both keys, or test with an SDK client instead of raw curl |
| HTTP 403 on a local HTTP server | Host or Origin validation rejected the request | Call it as 127.0.0.1 or localhost, or configure allowed hosts explicitly |
| VS Code ignores your config | Pasted a mcpServers file into .vscode/mcp.json |
VS Code wants servers with an explicit type |
TS build fails on Buffer or Node types |
TypeScript 6+ does not auto-include @types/node |
Add "types": ["node"] to compilerOptions |
| Claude Code warns about tool output size | A tool result exceeded 10,000 tokens (25,000 is the default cap) | Paginate or summarise in the tool; MAX_MCP_OUTPUT_TOKENS raises the cap |
uv build says it cannot determine which files to ship |
Package directory name differs from the project name | Set [tool.hatch.build.targets.wheel] packages as above |
FAQ
Should I use the Python or the TypeScript SDK?
Use the language of the system you are wrapping. Both are Tier 1 SDKs and both implement 2026-07-28. Python's decorators infer schemas from type hints and are faster to write; TypeScript's Zod schemas are more explicit and easier to constrain with bounds and patterns.
Do I still need FastMCP?
Not for the official path. The SDK's own high-level class was called FastMCP in 1.x and is MCPServer in 2.x. The separate fastmcp package from PrefectHQ is an independent framework with extra features; it is a fine choice, just do not mix its docs with the SDK's.
stdio or Streamable HTTP?
Stdio for a server that runs on the user's machine and acts with their local permissions. Streamable HTTP for anything shared, multi-user or deployed, and then you need authentication, which stdio never needed.
Will older clients work with a 2.x server?
In my tests, yes. Both servers answered a 2025-11-25 initialize request with protocolVersion: 2025-11-25, so one binary serves both protocol eras while clients catch up.
What I check first
When a new MCP server lands in a repo I review, I check four things before I look at the tools: that nothing writes to stdout, that versions are pinned exactly, that there is an in-process test calling every tool with good and bad input, and that every write-capable tool can be switched off with a flag. Those are just normal service hygiene applied to a new protocol, and the agent era makes them more important, not less, because the caller is a model that will happily retry a broken tool forty times. I build infrastructure for agent code execution at PandaStack (my company), and in my experience the servers that cause trouble are almost never the protocol's fault. Official references: the specification, the Python SDK, the TypeScript SDK and the Inspector. Before you connect someone else's server, read MCP Security in 2026.
Related: MCP in 2026: 475M SDK Downloads a Month, 39,492 Servers in a Registry Still in Preview, and 12 Gateways Selling the Same 3 Features, MCP Security in 2026: The Protocol Got Hardened. The Ecosystem Didn't. and Agent-to-Agent Protocols in 2026: A2A Reached 1.0, Three ACPs Died or Merged, and MCP Learned to Do Most of the Same Job.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Run an AI Coding Agent in GitHub Actions Safely (2026)
Step-by-step hardening for claude-code-action v1.0.254 and codex-action v1.13: SHA pinning, least-privilege permissions, safe triggers, keeping prompt injection from issues away from secrets, tool allowlists, turn caps, rulesets and troubleshooting.
15 minOct 20, 2026How to Deploy a Secure Remote MCP Server with OAuth (2026)
Step-by-step: run a remote MCP server over Streamable HTTP as an OAuth 2.1 resource server with Keycloak 26.8.1 and MCP Python SDK 2.3.0, validate audience and scopes per tool, add rate limits and audit logs, deploy to Cloud Run, and fix 401 and discovery errors.
16 minOct 5, 2026MCP in 2026: 475M SDK Downloads a Month, 39,492 Servers in a Registry Still in Preview, and 12 Gateways Selling the Same 3 Features
The MCP ecosystem ten months after the Linux Foundation took it over, measured from primary sources: 475 million SDK downloads a month across npm and PyPI, three spec revisions ending in the stateless 2026-07-28 rewrite, an official registry I paged to 39,492 servers (24,242 remote) that is still labelled preview while Glama lists 96,340, every major client with different controls, twelve gateways from $0 to $0.005 per thousand calls, the tool-overload numbers (55k tokens before the first prompt, 85 percent recoverable), and a stateless Python server on mcp 2.3.0 that I ran and tested.
14 min