AI Implementation

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.

Chase Dillingham

Chase Dillingham

Founder & CEO, TrainMyAgent

12 min read 16 sources cited
MCP MCP Server Tutorial Python TypeScript AI Development
Code editor showing MCP server implementation in Python with connected AI client

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 tutorialVersionCurrent v2 equivalent
from mcp.server.fastmcp import FastMCPPython v1from mcp.server import MCPServer
A tool that returns an error stringPython v1 habitraise ToolError("...")
npm install @modelcontextprotocol/sdkTypeScript v1npm install @modelcontextprotocol/server zod (zod 4.2 or later)
server.tool(name, description, shape, handler)TypeScript v1server.registerTool(name, { description, inputSchema }, handler)
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'TypeScript v1That path breaks. StdioServerTransport still works from @modelcontextprotocol/server/stdio, and serveStdio(createServer) is the new v2 helper.
initialize handshake, Mcp-Session-Id headerSpec 2025-11-25No handshake, no session. Each request stands alone.
MCP logging notificationsSpec 2025-11-25Deprecated (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 situationTransportAuth story
A tool for you or one developer machinestdioNone at the protocol level. The process that launched the server is the boundary.
A team shares one serverStreamable HTTPOAuth 2.1 bearer tokens, verified by your server
Several replicas behind a load balancerStreamable HTTPSame 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:

#TestPass
1List tools in the InspectorEvery tool appears with a description and typed parameters
2Call each read tool with valid inputCorrect structuredContent and readable content
3Send out-of-range or wrong-type inputRejected before the handler runs, with a readable message
4Look up an ID that does not existisError: true, not a success that contains an error sentence
5Call a write tool twice with one idempotency keyOne record created, same ID returned twice
6Run the launch command by handIt prints nothing to stdout and waits for input
7Run the CLI smoke test in CIExit 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 /mcp in a session to confirm the server is connected.
  • Claude Desktop: run uv run mcp install server.py. It writes the entry into claude_desktop_config.json with absolute paths and a pinned SDK version. Fully quit and reopen the app afterward.
  • Cursor: add the command under mcpServers in .cursor/mcp.json.
  • VS Code: add it under servers in .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:

  1. 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.
  2. Handles are not credentials. Servers “MUST NOT treat possession of a state handle as authentication.” Generate IDs with a secure random generator, as uuid4 and randomUUID do above, never TKT-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.
  3. 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

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