How to build an MCP server: an example in Python and TypeScript
The short version: an MCP server is a small program on an official SDK that declares tools — a name, a description and parameters. In Python a working server is about ten lines with the FastMCP library; in TypeScript, slightly more. You start with a local server (stdio), check it in MCP Inspector, connect it to Claude Code, and when the server has to be handed to other people you move it to HTTP. Below is the whole path, plus the rakes we stepped on running 26 remote servers in production.
First decide whether you need your own server
For the popular services — GitHub, Figma, Notion, a browser, search — servers already exist; see the best MCP servers. You write your own when the model needs access to yours: an internal API, a database, a CRM, the team's scripts. If you are still working out what MCP is, start with the overview of MCP servers.
An example in Python (FastMCP)
Install the SDK — uv is the most convenient way:
uv init my-mcp && cd my-mcp
uv add "mcp[cli]"The file server.py:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("orders")
@mcp.tool()
def order_status(order_id: str) -> str:
"""Order status by number. Use when the user asks where their order is."""
# here goes the call to your API or database
return f"Order {order_id}: handed over to the courier"
if __name__ == "__main__":
mcp.run()That is the entire server. FastMCP turns the function signature into a parameter schema and the docstring into the tool's description. The description is exactly what the model reads when deciding whether to call the tool, so write not "what the function does" but when to use it.
An example in TypeScript
npm init -y
npm install @modelcontextprotocol/sdk zodThe file server.ts (package.json needs "type": "module"):
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: 'orders', version: '1.0.0' });
server.registerTool(
'order_status',
{
description:
'Order status by number. Use when the user asks where their order is.',
inputSchema: { orderId: z.string().describe('Order number') },
},
async ({ orderId }) => ({
content: [{ type: 'text', text: `Order ${orderId}: handed over to the courier` }],
}),
);
await server.connect(new StdioServerTransport());A stdio server must never write to stdout
print() or console.log() breaks the protocol — the client gets garbage instead of JSON. Write logs to stderr (console.error; Python's logging goes there by default).Checking it in MCP Inspector
Before connecting the server to a client, check it in Inspector — the official debugger with a web interface: it shows the list of tools, their schemas, and lets you call each one by hand.
# Python
uv run mcp dev server.py
# TypeScript, and any other server
npx @modelcontextprotocol/inspector npx tsx server.tsConnecting to Claude Code and other clients
A local server is connected by its launch command. In Claude Code everything after -- is that command:
claude mcp add orders -- uv run --directory /path/to/my-mcp server.py
claude mcp add orders-ts -- npx tsx /path/to/server.tsIn clients with an mcp.json — Cursor, LM Studio and others — the same command is written as the command and args fields. Where each one keeps its config: Claude, Cursor, LM Studio, Codex, OpenCode.
From local to remote: HTTP
A local server only works for whoever launched it. To connect by URL — from claude.ai, from another machine, across a whole team — the server moves to the Streamable HTTP transport. In FastMCP that is one line:
mcp.run(transport="streamable-http")In TypeScript, StreamableHTTPServerTransport replaces StdioServerTransport inside your HTTP server (Express, Fastify). With HTTP come questions a local server never had:
| The question | What to settle |
|---|---|
| Who is calling | Sign-in: OAuth 2.1 as described in the MCP specification, or a key in the Authorization header. Claude.ai and Cursor can do OAuth; LM Studio and CI are easier with a key — supporting both is sensible |
| State | A stateless server is simpler to scale: every request stands on its own |
| Long calls | Operations that run for minutes hold the connection open — they need protection from timeouts (see below) |
| Cost | If a tool costs money, reserve the cost before calling the external API, not after |
Rakes from production
We keep 26 remote MCP servers running — search, image, video, music and speech generation. Here is what the short guides leave out, and what cost us incidents.
1. A tool name is longer than it looks
Many clients and models glue the server name and the tool name together — for example happyhorse-video-generation_happyhorse-reference-to-video. OpenAI-compatible APIs validate that name against ^[a-zA-Z0-9_-]{1,64}$ and, on a breach, reject the whole request rather than the one tool. Keep names short, ASCII, with no dots or spaces, and check the length together with the server prefix.
2. More than five minutes of silence kills the call
Video generation sends nothing until the result is ready. Popular Node.js HTTP clients default to 300-second header and body timeouts, and at exactly five minutes the client drops the connection — while the server carries on working, stores the file and charges for it. The cure is keepalive comments in the SSE stream (a line like : keepalive every 20 seconds): the event parser discards them, but the connection timers are reset.
3. Without the right 401, the client will not find OAuth
For Claude or Cursor to offer a sign-in by themselves, a request without a token must be answered with 401 plus the header WWW-Authenticate: Bearer resource_metadata="…" pointing at /.well-known/oauth-protected-resource, which in turn points at an authorization server with dynamic client registration and PKCE. Omit the header and the client simply shows "connection error".
4. The description is a prompt
The model picks a tool by its description alone. A vague "works with orders" leads either to needless calls or to the tool never being called at all. Write: when to call it, when not to call it, what comes back and what to do on an error. And return errors as text with a hint — the model will read it and fix the parameters itself.
If you want your own server just for search or generation
Frequently asked questions
How do I create my own MCP server?
@mcp.tool() decorator plus a call to mcp.run() is enough. Check the server in MCP Inspector (mcp dev server.py) and connect it to a client, for example claude mcp add name -- uv run server.py.Which language should I write an MCP server in?
How does a local MCP server differ from a remote one?
How do I debug an MCP server?
npx @modelcontextprotocol/inspector plus the server's launch command, or mcp dev server.py for Python. Inspector shows the tools and their schemas and lets you call each one by hand. In a local server, write logs to stderr: stdout belongs to the protocol.How do I add authorization to a remote MCP server?
WWW-Authenticate header pointing at the protected-resource metadata, and the authorization server supports PKCE and dynamic client registration. For clients without OAuth it is convenient to also accept a key in the Authorization: Bearer header.Try it yourself
Everything in this guide runs inside Twelver
One chat for text, images, video, music and voice — no separate services or subscriptions.
Open Twelver chat