How to build an MCP server: a step-by-step guide

To build an MCP server: pick the TypeScript or Python SDK, create a server instance, register one tool with a name, a description and an argument schema, and connect it to a transport — stdiofor local use, or streamable HTTP if you want it reachable over the network. That's the whole shape of it. The rest is what you put inside the tool. Below is a working walkthrough for both SDKs, grounded in the current Model Context Protocol docs, plus what changes the moment you take a server off your laptop and put it somewhere reachable.
Key takeaways
- Pick TypeScript (@modelcontextprotocol/server) or Python (mcp on PyPI) — both are Tier 1 official SDKs with near-identical capabilities.
- A tool is a name, a description, and an argument schema (Zod in TypeScript, type hints in Python) — the model only ever sees these three things.
- Run locally over stdio first; test with the MCP Inspector before wiring the server into any real client.
- Deploying as a remote streamable-HTTP server adds two new requirements: authentication and public hosting.
- A tool that works is not the same as a tool people will trust — see the companion piece on making it trustworthy once it runs.
Pick a language: TypeScript or Python
Both are official, Tier 1 SDKs maintained by the same team that maintains the protocol itself, and both cover the same ground: tools, resources, prompts, and both transport types. The choice mostly comes down to where the rest of your code already lives.
| TypeScript SDK | Python SDK | |
|---|---|---|
| Install | npm install @modelcontextprotocol/server zod | uv add "mcp[cli]" |
| Package | @modelcontextprotocol/server on npm | mcp on PyPI |
| Argument schema | Zod object schemas | Python type hints + docstrings |
| Minimal working server | ~13 lines including the transport | ~9 lines including the transport |
| Prefer it when | You're already in a Node/TS codebase, or you want the schema to double as a compile-time type | You're wrapping an existing Python library, a data pipeline, or an ML workflow |
Both SDKs are on major version 2, published from the typescript-sdk and python-sdk repos under the modelcontextprotocol GitHub org — the same org that publishes the spec itself, which is a good sign of provenance in its own right. If neither of those fits, there are also Tier 1 SDKs for C# and Go, plus Tier 2/3 SDKs for Java, Rust, Ruby, Swift, PHP and Kotlin, all listed on the official SDK page.
Set up the project
For TypeScript: create a directory, initialize npm, install the two packages above plus dev tooling.
mkdir my-server && cd my-server
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D typescript @types/nodeFor Python, the SDK docs assume uv: create the project, add the SDK with its CLI extras, and you have a virtual environment and a runnable file in under a minute.
uv init my-server && cd my-server
uv add "mcp[cli]"Both give you a plain project with one entry file. There's no scaffolding CLI to run and no framework to learn — an MCP server is just a program that speaks JSON-RPC over a transport, and the SDK handles the JSON-RPC part.
Define one tool, properly
This is the part that matters most, and it's smaller than people expect. A tool is a name, a description, and an argument schema — that's the entire contract the calling model sees. It never reads your source code or your README; it reads exactly what you register. Here's a minimal, illustrative example in TypeScript — trimmed down for clarity, not copy-paste production code:
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
const server = new McpServer({ name: "receipts", version: "1.0.0" });
server.registerTool(
"lookup_receipt",
{ description: "Look up a receipt by order ID", inputSchema: z.object({ orderId: z.string() }) },
async ({ orderId }) => ({ content: [{ type: "text", text: `Receipt for ${orderId}` }] }),
);
await server.connect(new StdioServerTransport());The same tool in Python — the SDK generates the schema from the type hint and the docstring instead of Zod:
from mcp.server import MCPServer
mcp = MCPServer("receipts")
@mcp.tool()
async def lookup_receipt(order_id: str) -> str:
"""Look up a receipt by order ID."""
return f"Receipt for {order_id}"
mcp.run(transport="stdio")Two things worth doing deliberately here, before you add a second tool. First, scope the argument schema tightly — a single required orderIdstring, not an open-ended object that accepts whatever the model feels like sending. Second, write the description as an instruction to the model about what the tool does and when to use it, not as marketing copy or an internal code comment; it's the only documentation the model ever gets.
Start with exactly one tool, get it working end to end, and only then add a second. It's tempting to register five or six tools up front because the boilerplate for each one is small, but a server with a sprawling tool surface is harder for a model to pick correctly between and, from a security standpoint, simply has more that can go wrong. One well-scoped tool that does its one job reliably beats five that half work.
Run it locally over stdio, then test it
stdio is the transport for local use: the client launches your server as a subprocess and talks to it over standard input/output. Run the TypeScript server with node after building, or the Python one directly with uv run — either way, nothing needs to be listening on a port.
One rule that trips people up immediately: never write to stdout in a stdio server. A stray console.log or print() corrupts the JSON-RPC stream and breaks the connection — use console.error / stderr logging instead.
Before wiring the server into Claude Desktop or any other client, point the MCP Inspector at it — the reference tool for testing and debugging a server in isolation, with a web UI, a scriptable CLI mode, and a terminal UI, all behind one binary:
npx @modelcontextprotocol/inspector node build/index.jsThat opens a browser session where you can list your tools, call them with real arguments, and see the exact JSON-RPC traffic — the fastest way to catch a broken schema before a model ever calls it. Before you consider the tool done, check three things in the Inspector: that tools/listshows the schema you think you wrote (not a typo in a field name), that calling the tool with valid arguments returns the content you expect, and that calling it with something malformed — a missing field, the wrong type — fails cleanly instead of throwing an unhandled exception that would otherwise surface as a cryptic error inside whatever client eventually connects to it. We cover the Inspector's other modes, including the CLI form useful in CI, in a dedicated walkthrough.
Going remote: streamable HTTP changes the rules
A local stdio server runs with your own permissions and is only ever reachable by whoever can launch it on your machine. The moment you deploy the same server as a remote, streamable-HTTP server — a single POST endpoint, typically something like https://yourserver.com/mcp— that changes: it's now reachable by anyone who has the URL, not just you. See how the trust model differs between the two in more depth, but the two practical changes are:
- Authentication becomes your responsibility. The protocol spec is direct about this:
Servers SHOULD implement proper authentication for all connections.
— MCP specification, Streamable HTTP transport
- You need somewhere to host it. A streamable-HTTP server is a long-running (or serverless-invoked) HTTP process, not a subprocess a client launches on demand — so it needs a host: a small VM, a container platform, or a serverless function runtime that can handle POST requests and, where needed, hold an SSE stream open for the duration of a single request.
The spec also calls out a narrower, easy-to-miss risk: if you're running a streamable-HTTP server locally for development, bind it to localhost rather than all network interfaces, and validate the Originheader — otherwise a malicious website can use DNS rebinding to talk to your local server through the visitor's own browser.
Make it discoverable and trustworthy
A server that runs correctly isn't automatically one people will connect to their agents — those are different bars. Getting discovered means publishing under a real, namespaced identity in the official registry rather than an anonymous handle. Getting trusted means the unglamorous stuff: a real license, honest tool descriptions, scoped permissions, versioned releases and someone actually maintaining it. We cover all of that, mapped to how Vouchity's Trust Score reads it, in a companion piece: building an MCP server people will actually trust. It's worth reading before you publish, not after — of the 320 servers currently in the Vouchity registry, 55% ship with no declared license at all, which is the single easiest thing on that list to get right from day one.
Frequently asked questions
Should I use the TypeScript SDK or the Python SDK to build an MCP server?
Both are official Tier 1 SDKs maintained by the Model Context Protocol team and cover the same ground — tools, resources, prompts, and both transports. Pick TypeScript if you're already in a Node codebase or want Zod schemas to double as compile-time types; pick Python if you're wrapping an existing Python library or data workflow.
What is the minimum I need to define to register an MCP tool?
A name, a description, and an argument schema — a Zod object schema in TypeScript, or a type-hinted function with a docstring in Python. That's the entire contract the calling model sees; it never reads your source code or README.
How do I test an MCP server before connecting it to a real client?
Run it locally over stdio, then point the MCP Inspector at it with npx @modelcontextprotocol/inspector node build/index.js. It gives you a web UI to list tools, call them with real arguments, and inspect the raw JSON-RPC traffic before any client touches the server.
What changes if I deploy an MCP server remotely instead of running it locally over stdio?
Two things: authentication becomes your responsibility, since a remote streamable-HTTP endpoint is reachable by anyone with the URL, not just you; and you need somewhere to host it, since it's a running HTTP process rather than a subprocess a client launches on demand.
Why does my stdio MCP server break when I add logging?
Because stdio servers communicate over standard output — any stray console.log() or print() call corrupts the JSON-RPC stream. Log to stderr instead (console.error() in TypeScript, the logging module in Python).
Trust Score changes, in your inbox
A weekly digest of newly flagged risks and the biggest Trust Score movers across the MCP registry. No spam, unsubscribe anytime.

