How to Build an MCP Server in Python and TypeScript (2026 SDKs)
A working MCP server tutorial for the v2 Python and TypeScript SDKs and the 2026-07-28 spec: tools with typed inputs and structured output, testing with the MCP Inspector, connecting Claude, Cursor, and VS Code, serving over Streamable HTTP, and the security rules to apply before anyone else uses it.
To build an MCP server, install an official SDK (Python mcp 2.x or TypeScript @modelcontextprotocol/server 2.x). Register functions as tools with typed inputs. Serve them over stdio, test them in the MCP Inspector, then give a host such as Claude Code, Cursor, or VS Code the command that starts your server.
Most MCP tutorials online were written for the v1 SDKs, and Python code that imports FastMCP fails on a fresh install. Everything below targets the 2026-07-28 spec and the SDKs current on October 15, 2026: Python mcp 2.3.0 and the TypeScript v2 packages.
New to MCP itself? Read what MCP is first. This page is the hands-on build.
Why older MCP tutorials break now
Two changes landed together in late July 2026: a new spec and a major version of both SDKs.
The 2026-07-28 spec changelog removed the initialize handshake and protocol sessions. Every request now carries its protocol version and client capabilities in _meta. Servers must implement a new server/discover method. The Logging, Sampling, and Roots features are deprecated, and the suggested replacement for Logging is stderr or OpenTelemetry. The SDK handles discovery and version routing, so the server code below never deals with them directly.
The SDKs moved at the same time. In Python, FastMCP is now MCPServer, and the old import path “is gone rather than deprecated.” Importing mcp.server.fastmcp on 2.x raises ModuleNotFoundError, and pip install mcp installs 2.x. The TypeScript SDK README says v2 shipped on 2026-07-27 as split packages, @modelcontextprotocol/server and @modelcontextprotocol/client. The v1 package, @modelcontextprotocol/sdk, gets bug and security fixes for at least six months after that date.
| What you see in a tutorial | Version | Current v2 equivalent |
|---|---|---|
from mcp.server.fastmcp import FastMCP | Python v1 | from mcp.server import MCPServer |
| A tool that returns an error string | Python v1 habit | raise ToolError("...") |
npm install @modelcontextprotocol/sdk | TypeScript v1 | npm install @modelcontextprotocol/server zod (zod 4.2 or later) |
server.tool(name, description, shape, handler) | TypeScript v1 | server.registerTool(name, { description, inputSchema }, handler) |
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' | TypeScript v1 | That path breaks. StdioServerTransport still works from @modelcontextprotocol/server/stdio, and serveStdio(createServer) is the new v2 helper. |
initialize handshake, Mcp-Session-Id header | Spec 2025-11-25 | No handshake, no session. Each request stands alone. |
| MCP logging notifications | Spec 2025-11-25 | Deprecated (still works). New code should log to stderr or OpenTelemetry. |
Decision rule: if code imports mcp.server.fastmcp or @modelcontextprotocol/sdk, it is v1. The Python v2 notes say v1.x moves to maintenance and still works, so to keep running v1 code, pin mcp>=1.28,<2. For a new server, start on v2. Both v2 SDKs still answer older clients from the same code. In TypeScript, the v2 upgrade guide says the deprecated variadic .tool(), .prompt(), and .resource() methods are removed. Use registerTool, registerPrompt, and registerResource with a config object.
Before you start: pick a language and a transport
You need one of these runtimes. Use Python 3.11 or later. The SDK install docs require 3.11+, while the PyPI metadata for mcp 2.3.0 still lists >=3.10. The TypeScript tutorial requires Node.js 20 or later, and the project must use ES modules (npm pkg set type=module). The MCP Inspector, your test tool, is a Node app. Its v2 line requires Node 22.19.0 or later, so install that version even for a Python server.
The transport decides how bytes reach your server. stdio means the host starts your server as a child process and talks over stdin and stdout. Streamable HTTP means your server listens on a URL that many clients share.
| Your situation | Transport | Auth story |
|---|---|---|
| A tool for you or one developer machine | stdio | None at the protocol level. The process that launched the server is the boundary. |
| A team shares one server | Streamable HTTP | OAuth 2.1 bearer tokens, verified by your server |
| Several replicas behind a load balancer | Streamable HTTP | Same as above, plus shared state in your own store |
Decision rule: start on stdio. Move to HTTP only when someone who does not have your files needs the server. The tool code is identical in both cases. Only the last few lines change.
How to build an MCP server in Python
The example is a small customer-data server: two read tools, one write tool, one resource, and one prompt. Install the SDK with the CLI extra, which adds the mcp command:
uv add "mcp[cli]" # or: pip install "mcp[cli]"
Create server.py:
import uuid
from typing import Annotated, Literal
from pydantic import BaseModel, Field
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
mcp = MCPServer("Customer Data")
# Sample data. Replace with your database or API client.
CUSTOMERS = {
"C001": {"name": "Acme Corp", "plan": "Enterprise", "mrr": 12500, "health": "green"},
"C002": {"name": "TechStart Inc", "plan": "Growth", "mrr": 2500, "health": "yellow"},
"C003": {"name": "MegaRetail", "plan": "Enterprise", "mrr": 45000, "health": "green"},
"C004": {"name": "FastShip LLC", "plan": "Starter", "mrr": 500, "health": "red"},
}
TICKETS: dict[str, "Ticket"] = {} # keyed by idempotency key; use a real store in production
Health = Literal["green", "yellow", "red"]
class Customer(BaseModel):
id: str
name: str
plan: str
mrr: int = Field(description="Monthly recurring revenue in US dollars.")
health: Health
class Ticket(BaseModel):
id: str
customer_id: str
subject: str
priority: str
status: str
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def search_customers(
query: Annotated[str, Field(description="Text to match against name or plan. Empty matches all.")] = "",
health: Health | None = None,
limit: Annotated[int, Field(ge=1, le=25, description="Maximum results to return.")] = 10,
) -> list[Customer]:
"""Search customers by name or plan, optionally filtered by health score."""
q = query.lower()
hits = [
Customer(id=cid, **c)
for cid, c in CUSTOMERS.items()
if (q in c["name"].lower() or q in c["plan"].lower())
and (health is None or c["health"] == health)
]
return hits[:limit]
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def get_customer(customer_id: Annotated[str, Field(description="Customer ID, for example C001.")]) -> Customer:
"""Get one customer's plan, MRR, and health score by ID."""
if customer_id not in CUSTOMERS:
raise ToolError(f"No customer with ID {customer_id!r}. Call search_customers to find the ID.")
return Customer(id=customer_id, **CUSTOMERS[customer_id])
@mcp.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=True))
def create_ticket(
customer_id: str,
subject: Annotated[str, Field(min_length=3, max_length=200)],
idempotency_key: Annotated[str, Field(min_length=8, description="Unique per request. Reuse it on retry.")],
priority: Literal["low", "medium", "high", "critical"] = "medium",
) -> Ticket:
"""Open a support ticket. A retry with the same idempotency_key returns the original ticket."""
if customer_id not in CUSTOMERS:
raise ToolError(f"No customer with ID {customer_id!r}.")
if idempotency_key in TICKETS:
return TICKETS[idempotency_key]
ticket = Ticket(id=str(uuid.uuid4()), customer_id=customer_id, subject=subject,
priority=priority, status="open")
TICKETS[idempotency_key] = ticket
return ticket
@mcp.resource("customers://summary")
def customer_summary() -> str:
"""Customer count, total MRR, and health distribution."""
total = sum(c["mrr"] for c in CUSTOMERS.values())
by_health: dict[str, int] = {}
for c in CUSTOMERS.values():
by_health[c["health"]] = by_health.get(c["health"], 0) + 1
return f"Customers: {len(CUSTOMERS)}\nTotal MRR: ${total:,}\nHealth: {by_health}"
@mcp.prompt()
def at_risk_review() -> str:
"""Review every customer with a yellow or red health score."""
return (
"Use search_customers with health set to yellow, then red. For each customer, "
"give the name, MRR, health, and one specific retention action."
)
if __name__ == "__main__":
mcp.run() # stdio by default
Three things in this file do real work.
Type hints are the contract. The SDK builds each tool’s JSON Schema from the parameters. Field(ge=1, le=25) becomes a minimum and maximum, and Literal becomes an enum. The SDK rejects a bad call before your function runs and sends the model a message it can act on.
Return types are structured output. Because get_customer returns a Pydantic model, the SDK publishes an output schema and fills both parts of the result: content holds text for the model, and structuredContent holds typed JSON for the client application. When a tool declares an outputSchema, the tools spec says servers “MUST provide structured results that conform to this schema,” and a server that returns structured content should also return the serialized JSON as text.
Errors are raised, not returned. The error handling docs are blunt: “Never return an error message from a tool.” A returned string has is_error=False, so the model treats it as a successful answer. ToolError sets the flag, and the model can retry with a better argument.
The idempotency_key argument exists for one failure mode. Under 2026-07-28, “a broken response stream loses the in-flight request,” and the client must send it again. If your server created the ticket before the stream broke, a naive retry creates a second one. A retry repeats the same arguments, so the key in those arguments lets the server return the first ticket. The tool design guide covers this pattern in depth.
How to build an MCP server in TypeScript
Set up the project the way the official tutorial does. tsx runs TypeScript directly, so there is no build step:
mkdir customer-mcp && cd customer-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod tsx # needs zod 4.2 or later
mkdir src
Create src/index.ts with the same two core tools:
import { randomUUID } from 'node:crypto';
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
const Customer = z.object({
id: z.string(),
name: z.string(),
plan: z.string(),
mrr: z.number(),
health: z.enum(['green', 'yellow', 'red'])
});
// Sample data. Replace with your database or API client.
const CUSTOMERS: z.infer<typeof Customer>[] = [
{ id: 'C001', name: 'Acme Corp', plan: 'Enterprise', mrr: 12500, health: 'green' },
{ id: 'C002', name: 'TechStart Inc', plan: 'Growth', mrr: 2500, health: 'yellow' },
{ id: 'C003', name: 'MegaRetail', plan: 'Enterprise', mrr: 45000, health: 'green' },
{ id: 'C004', name: 'FastShip LLC', plan: 'Starter', mrr: 500, health: 'red' }
];
// Module scope: shared by every server instance the factory creates.
const tickets = new Map<string, { id: string; customerId: string; subject: string; priority: string }>();
function createServer(): McpServer {
const server = new McpServer({ name: 'customer-data', version: '1.0.0' });
server.registerTool(
'search_customers',
{
description: 'Search customers by name or plan, optionally filtered by health score.',
inputSchema: z.object({
query: z.string().describe('Text to match against name or plan. Empty matches all.'),
health: z.enum(['green', 'yellow', 'red']).optional(),
limit: z.number().int().min(1).max(25).optional()
}),
outputSchema: z.object({ customers: z.array(Customer) }),
annotations: { readOnlyHint: true, openWorldHint: false }
},
async ({ query, health, limit }) => {
const q = query.toLowerCase();
const customers = CUSTOMERS.filter(
c => (c.name.toLowerCase().includes(q) || c.plan.toLowerCase().includes(q)) && (!health || c.health === health)
).slice(0, limit ?? 10);
const output = { customers };
return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output };
}
);
server.registerTool(
'create_ticket',
{
description: 'Open a support ticket. A retry with the same idempotencyKey returns the original ticket.',
inputSchema: z.object({
customerId: z.string(),
subject: z.string().min(3).max(200),
priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
idempotencyKey: z.string().min(8).describe('Unique per request. Reuse it on retry.')
}),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
},
async ({ customerId, subject, priority, idempotencyKey }) => {
if (!CUSTOMERS.some(c => c.id === customerId)) {
return { content: [{ type: 'text', text: `No customer with ID ${customerId}.` }], isError: true };
}
let ticket = tickets.get(idempotencyKey);
if (!ticket) {
ticket = { id: randomUUID(), customerId, subject, priority: priority ?? 'medium' };
tickets.set(idempotencyKey, ticket);
}
return { content: [{ type: 'text', text: JSON.stringify(ticket) }] };
}
);
return server;
}
void serveStdio(createServer);
console.error('customer-data MCP server running on stdio');
The shape matches the Python version. One Zod schema per tool gives the model its JSON Schema, validates arguments, and types the handler. outputSchema plus structuredContent adds typed results that must match the declared schema. A failed call returns isError: true, so the model can read the message and recover.
Two details prevent real bugs. First, serveStdio takes a factory function, not a server instance. Over HTTP, the Express guide says “the factory runs once per request, so a fresh McpServer serves every call.” Keep shared state, like the tickets map or a database pool, at module scope, or each request sees an empty copy. Second, the banner goes to console.error. On stdio, stdout is the protocol channel.
How do you test an MCP server?
Test in three layers, from fastest feedback to most realistic.
The Inspector UI. For Python, run uv run mcp dev server.py. For TypeScript, run npx @modelcontextprotocol/inspector npx tsx src/index.ts. Click Connect, open Tools, and call each tool by hand. Try bad input on purpose, such as limit: 99, and confirm the SDK rejects it before your code runs.
The Inspector CLI in CI. The Inspector smoke-testing guide shows a scriptable mode with stable exit codes: 3 for auth required, 4 for unreachable, 5 for a tool error. It also warns that npx resolves the latest release on each run, so pin an exact version in CI. Use --tool-args-json, because --tool-arg turns all-digit strings like order numbers into numbers.
npx --yes @modelcontextprotocol/inspector@2.11.0 --cli uv run python server.py \
--method tools/call --tool-name get_customer \
--tool-args-json '{"customer_id":"C001"}' --format json \
| jq -e '.result.structuredContent.health == "green"'
In-memory tests. The Python SDK’s Client accepts your server object directly: async with Client(mcp, raise_exceptions=True) as client:. No subprocess, no port. For evaluating how a model chooses among your tools, see agent evals in CI.
Pre-ship checklist. Each row passes or fails:
| # | Test | Pass |
|---|---|---|
| 1 | List tools in the Inspector | Every tool appears with a description and typed parameters |
| 2 | Call each read tool with valid input | Correct structuredContent and readable content |
| 3 | Send out-of-range or wrong-type input | Rejected before the handler runs, with a readable message |
| 4 | Look up an ID that does not exist | isError: true, not a success that contains an error sentence |
| 5 | Call a write tool twice with one idempotency key | One record created, same ID returned twice |
| 6 | Run the launch command by hand | It prints nothing to stdout and waits for input |
| 7 | Run the CLI smoke test in CI | Exit code 0 on a pinned Inspector version |
Connect it to Claude Code, Cursor, VS Code, or Claude Desktop
Every host needs the same thing: the command that starts your server. The Python host guide recommends one launch command for every host: uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py.
- Claude Code: run
claude mcp add customer-data -- <launch command>, then type/mcpin a session to confirm the server is connected. - Claude Desktop: run
uv run mcp install server.py. It writes the entry intoclaude_desktop_config.jsonwith absolute paths and a pinned SDK version. Fully quit and reopen the app afterward. - Cursor: add the command under
mcpServersin.cursor/mcp.json. - VS Code: add it under
serversin.vscode/mcp.json, with"type": "stdio". Copilot Chat must be in Agent mode to call tools.
The TypeScript server uses its own launch command, npx tsx src/index.ts, run from the project root. Its VS Code entry looks like this:
{
"servers": {
"customer-data": {
"type": "stdio",
"command": "npx",
"args": ["tsx", "src/index.ts"]
}
}
}
When a host does not show your tools, the Python guide names three usual causes. A relative path fails because the host launches from its own working directory. An old config stays loaded until the host restarts. Stray output on stdout corrupts the stream, because on stdio, stdout is the protocol channel. Run the launch command by hand first. If it exits or prints a traceback, the bug is in the command, not the host. Claude Desktop also keeps each server’s stderr in mcp-server-<NAME>.log under ~/Library/Logs/Claude on macOS.
A host launches your server with a near-empty environment, so variables from your shell are not there. Put API keys in the host config’s env field (mcp install -v API_KEY=... writes it for Claude Desktop), never in code.
Going remote: Streamable HTTP and auth
For a shared server, switch the last line. In Python, mcp.run(transport="streamable-http") serves http://127.0.0.1:8000/mcp. In TypeScript, follow the Express guide: wrap the same factory with createMcpHandler, adapt it with toNodeHandler, and mount it on one route.
npm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express
Replace the last two lines of src/index.ts with:
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler } from '@modelcontextprotocol/server';
const handler = createMcpHandler(createServer); // the same factory as before
const app = createMcpExpressApp();
const node = toNodeHandler(handler);
app.all('/mcp', (req, res) => void node(req, res, req.body));
app.listen(3000, '127.0.0.1');
Move the imports to the top of the file. createMcpExpressApp() already parses JSON, which is why req.body is passed through.
The first deploy usually fails on one setting. The Python deploy guide explains that the HTTP app accepts only localhost Host headers by default. This protects a local server from DNS rebinding. Behind a real hostname, every request gets 421 Misdirected Request until you allowlist it:
from mcp.server.transport_security import TransportSecuritySettings
security = TransportSecuritySettings(allowed_hosts=["mcp.example.com", "mcp.example.com:*"])
app = mcp.streamable_http_app(transport_security=security)
# Run with any ASGI server, for example: uvicorn server:app --workers 4
A 2026-07-28 request is one self-contained POST with no session, so the deploy guide says to “route it to any worker.” Sticky sessions are no longer needed for modern clients. Server-side state still needs a shared store. The in-memory TICKETS dict above works for one process only.
Auth is your job on HTTP. The Python authorization guide describes your server as an OAuth 2.1 resource server. It verifies tokens and never issues them. You implement a TokenVerifier and pass AuthSettings with the issuer, the server’s public URL, and required scopes. The SDK then publishes the discovery document clients need. Set validate_token_resource=True when your authorization server binds tokens to the resource, so the server rejects tokens issued for another service. On the TypeScript side, createMcpExpressApp() checks the Host and Origin headers on localhost binds. Binding to all interfaces drops that default, so name the hosts you serve with allowedHosts. Token verification is still yours: put requireBearerAuth from @modelcontextprotocol/express in front of the route.
For version skew between old and new clients, gateways, and the full migration checklist, see the MCP roadmap and upgrade guide. For central policy across many servers, see MCP gateways.
Security basics before anyone else uses it
The tools spec lists four things servers “MUST” do: validate all tool inputs, implement proper access controls, rate limit tool invocations, and sanitize tool outputs. The SDK covers schema validation. The other three are your code.
The Streamable HTTP spec adds one MUST and two SHOULDs. Servers MUST validate the Origin header on every connection and answer an invalid one with HTTP 403. They SHOULD bind to 127.0.0.1, not 0.0.0.0, when running locally, and SHOULD authenticate all connections.
The security best practices add three points that matter for a first server:
- A local server runs with your privileges. The guide lists malicious startup commands and DNS rebinding of an insecure localhost server as attack paths. For local use, it recommends stdio. If you use HTTP locally, it recommends an authorization token or a restricted IPC channel.
- Handles are not credentials. Servers “MUST NOT treat possession of a state handle as authentication.” Generate IDs with a secure random generator, as
uuid4andrandomUUIDdo above, neverTKT-1001,TKT-1002. Key stored state as<user_id>:<handle>, with the user ID taken from the verified token. The same rule applies to idempotency keys on a multi-user server. - Never pass tokens through. The guide says the authorization spec explicitly forbids accepting tokens not issued to your server and forwarding them downstream. Accept only tokens issued for your server, and call downstream APIs with credentials issued for those APIs.
Tool annotations such as read_only_hint are hints for the client, not controls. The tools spec says clients “MUST consider tool annotations to be untrusted unless they come from trusted servers.” If a host does trust your server, it may use a read-only hint to skip a confirmation prompt, so keep every hint accurate. Enforce permissions in the handler regardless.
Decision rule (mine): before a second person uses the server, require three things. Every write tool takes an idempotency key. Every tool checks the caller’s permission inside the handler. Logs go to stderr and record who called which tool. Tool poisoning, permission models, and audit trails are covered in the MCP security guide.
Bottom Line
Building an MCP server is a short job when you start on the current SDKs: typed functions, one decorator or registerTool call each, and a launch command.
Most of the risk is in old tutorials and in the steps after “it works on my machine.” Raise errors, add idempotency keys to writes, keep stdout clean, and test in the Inspector before connecting a host.
Next step: build the stdio version, pass the seven-row checklist, and only then move to HTTP with auth. For how the model calls your tools, see tool calling.
FAQ
Is FastMCP still the way to build an MCP server in Python?
Not under that name in the official SDK. In mcp 2.x the class is MCPServer, imported from mcp.server. The old mcp.server.fastmcp module raises ModuleNotFoundError with a pointer to the migration guide. Decorator-based servers mostly port by changing the import and class name, plus renames such as ctx.fastmcp to ctx.mcp_server.
Which MCP protocol version should a new server target?
2026-07-28, the current revision. Both v2 SDKs serve older clients from the same code. Python’s HTTP transport routes each request by its MCP-Protocol-Version header, so both eras reach one endpoint. The TypeScript v2 upgrade guide notes that v2 still negotiates the 2025-era initialize handshake with older peers.
Do I have to implement server/discover myself?
No. The spec requires every server to implement it, and both v2 SDKs answer it for you. Clients may call it to learn your supported versions and capabilities before anything else. On stdio, it also lets a client detect whether your server is modern or legacy.
How do I log from an MCP server without breaking it?
Write to stderr. On stdio, anything on stdout that is not an MCP message corrupts the stream, and hosts drop the connection. In Python, the default logging setup writes to stderr. In TypeScript, use console.error. The 2026-07-28 spec deprecated MCP’s own Logging feature and suggests stderr or OpenTelemetry instead.
What happens if a tool call’s HTTP stream drops mid-request?
The request is lost. The 2026-07-28 spec removed stream resumability, so the client must send the call again with a new request ID. Any side effect that finished before the drop can run twice unless the tool takes an idempotency key and the server stores keys it has already seen.
Three Ways to Work With TMA
Need an agent built? We deploy production AI agents in your infrastructure. Working pilot. Real data. Measurable ROI. → Schedule Demo
Want to co-build a product? We’re not a dev agency. We’re co-builders. Shared cost. Shared upside. → Partner with Us
Want to join the Guild? Ship pilots, earn bounties, share profit. Community + equity + path to exit. → Become an AI Architect
Need this implemented?
We design and deploy enterprise AI agents in your environment with measurable ROI and production guardrails.
About the Author
Chase Dillingham
Founder & CEO, TrainMyAgent
Chase Dillingham is the founder and CEO of TrainMyAgent. He spent 15+ years as an enterprise architect deploying production systems. Now he builds AI agent platforms that deliver measurable ROI.
Connect with Chase Dillingham on LinkedIn