How to Build an MCP Server: TypeScript and Python, Tested
Build a remote MCP server over Streamable HTTP with the official TypeScript and Python SDKs, then check it from outside before you launch. Every example was run on 24 October 2026.
An MCP server is a program that offers tools to an AI client: Claude, ChatGPT, Cursor, VS Code and the rest call it, read its tool list, and use those tools for the user. This guide builds the same small server twice, once with the official TypeScript SDK and once with the official Python SDK, serves it over HTTP so any client can reach it by URL, and checks it the way a client will.
Both servers below were run on 24 October 2026, and our connection tester code connected to each of them, negotiated protocol revision 2026-07-28, and listed one tool with its description. The versions used were @modelcontextprotocol/server 2.3.1 and mcp 2.3.0 for Python.
TL;DR:
- Build it remote. A server served over Streamable HTTP has a URL, so a client in the cloud can reach it and a directory can test it. A local stdio server runs only on the machine of whoever installs it (remote vs stdio).
- Both official SDKs are on v2, and both turn a typed function into a tool: you write no JSON Schema by hand [1][3].
- The SDK builds the protocol for you, not the tool list. What a model sees is your tool names and descriptions, so most of the build decisions that matter are in those.
- Test from outside before you launch. Your machine reaches localhost; nobody else does.
What you are building
One tool, count_words, that counts the words in a text. It is small enough to read in one screen and has everything a real tool has: a name, a description a model reads to decide when to call it, a typed input, a result, and a read-only hint telling clients it changes nothing.
The server answers at /mcp. That one URL takes every MCP request as an HTTP POST with a JSON-RPC body, which is the Streamable HTTP transport both SDKs serve.
TypeScript
Install the server SDK, its Express and Node adapters, Express itself, and zod for the input schema:
npm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express zod
Create server.ts:
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const handler = createMcpHandler(() => {
const server = new McpServer({ name: 'word-tools', version: '1.0.0' });
server.registerTool(
'count_words',
{
description: 'Count the words in a piece of text. Use it when the user asks how long a text is.',
inputSchema: z.object({
text: z.string().describe('The text to count the words of')
}),
annotations: { readOnlyHint: true }
},
async ({ text }) => {
const words = text.trim() === '' ? 0 : text.trim().split(/\s+/).length;
return { content: [{ type: 'text', text: `${words} words` }] };
}
);
return server;
});
const app = createMcpExpressApp();
const node = toNodeHandler(handler);
app.all('/mcp', (req, res) => void node(req, res, req.body));
app.listen(3000);
Run it with npx tsx server.ts. The endpoint is http://127.0.0.1:3000/mcp.
Three things in that file are worth knowing before you add more tools:
- The function passed to
createMcpHandlerruns once per request, and builds a fresh server each time. The SDK's own guide says "the endpoint is stateless and scales horizontally as-is" [1]. Register tools inside that function, and keep database pools and caches outside it. - The zod schema is the only schema you write. The SDK turns it into the JSON Schema clients see and validates arguments before your handler runs, and the
.describe()text is the only documentation a model gets for that argument [2]. createMcpExpressApp()checks theHostheader against localhost by default, which is what stops a web page from reaching your local server through DNS rebinding [4]. When you deploy, you name your real host (see Deploy it).
Python
Install the SDK:
pip install mcp
Create server.py:
from mcp.server import MCPServer
from mcp.types import ToolAnnotations
mcp = MCPServer("word-tools", version="1.0.0")
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
def count_words(text: str) -> str:
"""Count the words in a piece of text. Use it when the user asks how long a text is."""
return f"{len(text.split())} words"
if __name__ == "__main__":
mcp.run(transport="streamable-http")
Run it with python server.py. The endpoint is http://127.0.0.1:8000/mcp.
The function is the tool. Its name is the tool name, its docstring is the description, and text: str is the input schema [3]. The return type does work too: in our run the server also advertised an outputSchema built from -> str, so a client knows the shape of the result before it calls.
Pass version to MCPServer. Without it the server reported an empty version in its handshake when we tested it, which is what a directory or a client shows next to your server's name.
By default the Python server keeps a session per client: on a 2025-11-25 handshake it handed back an Mcp-Session-Id, and a request without one was refused with "Missing session ID". If you run more than one instance behind a load balancer, mcp.run(transport="streamable-http", stateless_http=True) answered without any session in our test, so any instance can take any request.
Check that it answers
With the server running, list its tools the way a client does. The Python SDK includes a client, and it works against either server:
import asyncio
from mcp import Client
async def main() -> None:
async with Client("http://127.0.0.1:3000/mcp") as client:
tools = await client.list_tools()
print([tool.name for tool in tools.tools])
result = await client.call_tool("count_words", {"text": "ship the release notes"})
print(result.content[0].text)
asyncio.run(main())
Against both servers this printed ['count_words'] and then 4 words.
To see the raw protocol instead, test the server with curl: it walks through the exact requests for both protocol revisions and how to read each response. For a UI, the MCP Inspector runs locally.
What to decide while you build
The SDK handles the protocol. What it cannot decide for you is what a model sees, and that is where servers differ. These are the decisions, with what real servers did when we called them.
How many tools. In our registry census on 20 August 2026, the 694 servers that listed their tools had a median of 7, and the largest had 163. Clients send every enabled tool to the model on every request, and Cursor warns past 40 across all servers. Expose the jobs a user asks for, not one tool per API route. How many tools an MCP server should have has the limits per client.
What each description says. All 35 servers that connected in our hand-picked set carried descriptions on their tools, so having one is not what sets a server apart. Saying when to use the tool, next to its siblings, is. Writing MCP tool descriptions shows what good ones contain.
Whether to require sign-in. 263 of the 1,100 registry addresses we called (23.9%) answered by asking for credentials. That is a working server guarding itself, and it is the right choice when the tools touch a user's own data. The TypeScript SDK leaves the token check to you, in front of the handler [1]. MCP OAuth covers what a client expects when it gets a 401.
Which protocol revision to speak. Of the 694 servers that completed a handshake in the census, 382 negotiated 2025-11-25 and 3 negotiated 2026-07-28. A client that has not moved to 2026-07-28 sends the older handshake, so a server has to answer both. The TypeScript v2 handler "serves 2025-era clients statelessly from the same factory by default" [1], and both of our servers answered a 2025-11-25 handshake as well as the newer revision. You get this for free by staying on the SDK rather than writing the transport yourself.
Deploy it
A server that answers on 127.0.0.1 answers only you. To launch it, it needs a public HTTPS URL, and the Host check has to name that URL.
TypeScript: bind to all interfaces and list the hosts you serve [4]:
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] });
In our test that answered a request for api.example.com and refused any other host with a 403.
Python: pass the host and the same list:
from mcp.server.transport_security import TransportSecuritySettings
mcp.run(
transport="streamable-http",
host="0.0.0.0",
transport_security=TransportSecuritySettings(allowed_hosts=["api.example.com"]),
)
Set allowed_hosts. Bound to 0.0.0.0 without it, our Python server accepted a request carrying any host. With it, a request for another host got a 421.
Put TLS in front with whatever already serves your other HTTPS traffic (a reverse proxy, a platform's load balancer), and the endpoint your users paste into a client is https://api.example.com/mcp.
Test it from outside, then launch it
Once it is deployed, test the public URL from our network. It runs the same check our listings use: it tries the newer revision first, falls back to the handshake, lists the tools, and saves the result at its own dated URL. A server that passes there is reachable by a client you have never met. One that fails shows you which step failed (DNS, TLS, a 401, a protocol error) before your users find it.
Then get it in front of people. How to launch an MCP server covers the registry, the client directories and where installs come from. When you are ready, submit it to MCP Hunter: it is free, a human reviews it, and where we can connect to your server the listing carries what it returned, dated.