Field notes
Which language should you build an MCP server in?
We built the same MCP server in TypeScript, Python, Go and Rust, then measured startup, dependencies and binary size. The numbers pick for you.
MCPOrbit Team
Engineering, MCPOrbit
- Published
- Updated
- · Updated
- Read time
- · 11 min read
Pick the language your team already ships. If that leaves a genuine choice: use Python to get something working today, TypeScript if the server will also run over HTTP, Go if you need one binary you can hand to somebody, and Rust if the server starts and stops constantly. Every MCP feature you care about exists in all four SDKs, so this is a question about packaging and startup, not capability.
We got tired of answering this from memory, so we built the same server four times. One tool, called reverse_text, taking one string and returning it backwards. Identical behavior in every language. Then we measured what actually differs.
The measurements, side by side
Everything below was measured on one machine, an M-series Mac, on 2026-08-28. Startup is the median of six warm runs, timed from process spawn to the initialize response landing on stdout. The first run of each binary is discarded because it pays a cold page-cache cost that no real client sees twice.
TypeScript Python Go Rust
SDK server 2.0.0 mcp 2.1.1 v1.7.0 rmcp 3.1.4
Lines for one tool 18 8 26 46
Dependencies 3 packages 28 packages 8 modules 81 crates
Dev tree on disk 14.4 MB 35.0 MB 63.4 MB 214.7 MB
Shipped artifact source + deps source + deps 9.4 MB bin 2.6 MB bin
Handshake (warm median) 120.6 ms 454.6 ms 6.5 ms 3.5 ms
Negotiated revision 2025-11-25 2025-11-25 2025-11-25 2026-07-28
Auto outputSchema no yes yes noTwo of those columns surprised us, and they are the reason this post exists rather than a table of vibes.
Why is Python's MCP server 130x slower to start than Rust's?
Because it imports a web framework to serve stdio. Installing mcp 2.1.1 pulls 28 packages, and the list includes starlette, uvicorn, sse-starlette and cryptography. Those exist for the HTTP transport. You pay their import cost on every start even when your server only speaks stdio.
454 ms sounds survivable, and for a long-lived server it is. It stops being survivable when a client spawns your server per request, or when a user has fifteen stdio servers configured and every one of them costs half a second of app startup. That is the case where the number matters.
$ uv pip list | wc -l
28
$ uv pip list | grep -E 'starlette|uvicorn|cryptography'
cryptography==50.0.1
sse-starlette==3.4.8
starlette==1.6.0
uvicorn==0.52.4What does each SDK actually make you write?
Line counts are a crude proxy, but the shape of the code is not. Here is the same tool in all four, complete and runnable.
Python: 8 lines, and the type hints are the schema
from mcp.server import MCPServer
mcp = MCPServer("reverse-py")
@mcp.tool()
def reverse_text(text: str) -> str:
"""Reverse a string."""
return text[::-1]
if __name__ == "__main__":
mcp.run()Nothing else. The parameter annotation becomes the input schema, the docstring becomes the description, and the return annotation becomes an outputSchema. This is the shortest path from idea to a working tool in any of the four, and it is not close.
TypeScript: 18 lines, Zod carries the schema
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
function createServer() {
const server = new McpServer({ name: "reverse-ts", version: "1.0.0" });
server.registerTool(
"reverse_text",
{
description: "Reverse a string.",
inputSchema: { text: z.string().describe("The text to reverse") },
},
async ({ text }) => ({
content: [{ type: "text", text: [...text].reverse().join("") }],
}),
);
return server;
}
serveStdio(createServer, { onerror: (e) => console.error("[mcp]", e.message) });Two things in that snippet cost us time, and neither is in the quickstart. serveStdio lives at the @modelcontextprotocol/server/stdio subpath, not the package root, so an HTTP-only deployment does not drag in the stdio shims. And it takes a factory function, not a server instance.
Go: 26 lines, struct tags carry the schema
package main
import (
"context"
"log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type ReverseInput struct {
Text string `json:"text" jsonschema:"the text to reverse"`
}
type ReverseOutput struct {
Reversed string `json:"reversed" jsonschema:"the reversed text"`
}
func Reverse(ctx context.Context, req *mcp.CallToolRequest, in ReverseInput) (*mcp.CallToolResult, ReverseOutput, error) {
r := []rune(in.Text)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return nil, ReverseOutput{Reversed: string(r)}, nil
}
func main() {
server := mcp.NewServer(&mcp.Implementation{Name: "reverse-go", Version: "1.0.0"}, nil)
mcp.AddTool(server, &mcp.Tool{Name: "reverse_text", Description: "Reverse a string."}, Reverse)
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
log.Fatal(err)
}
}The typed return value is the interesting part. Because Reverse returns a concrete ReverseOutput, the SDK generates an outputSchema from the struct and emits structuredContent on every call without being asked. Python does the same from its return annotation. TypeScript and Rust both return content blocks by default and make structured output an explicit choice.
Rust: 46 lines, and the compiler negotiates
use rmcp::{
ErrorData, ServerHandler, ServiceExt,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerInfo},
tool, tool_handler, tool_router,
transport::stdio,
};
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ReverseArgs {
/// The text to reverse
pub text: String,
}
#[derive(Clone)]
pub struct Reverse {
tool_router: ToolRouter<Self>,
}
#[tool_router]
impl Reverse {
pub fn new() -> Self {
Self { tool_router: Self::tool_router() }
}
#[tool(description = "Reverse a string.")]
async fn reverse_text(
&self,
Parameters(args): Parameters<ReverseArgs>,
) -> Result<CallToolResult, ErrorData> {
let out: String = args.text.chars().rev().collect();
Ok(CallToolResult::success(vec![ContentBlock::text(out)]))
}
}
#[tool_handler]
impl ServerHandler for Reverse {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("reverse-rs", "1.0.0"))
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let service = Reverse::new().serve(stdio()).await?;
service.waiting().await?;
Ok(())
}Two compile errors are worth knowing about before you start. rmcp 3.1.4 calls the content type ContentBlock, not Content. And both Implementation and ServerInfo are marked #[non_exhaustive], so a struct literal fails with E0639 no matter how many fields you fill in. Use the constructors: Implementation::new(name, version) and ServerInfo::new(capabilities).with_server_info(...).
Do all four SDKs support the same MCP spec revision?
No, and this is the finding we did not expect. We sent every server an identical initialize asking for protocol version 2026-07-28. Only rmcp answered with 2026-07-28. Go and Python both negotiated down to 2025-11-25. TypeScript rejected 2026-07-28 outright over the legacy initialize path and had to be asked for 2025-11-25.
// @modelcontextprotocol/server 2.0.0
import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from "@modelcontextprotocol/server";
LATEST_PROTOCOL_VERSION;
// "2025-11-25"
SUPPORTED_PROTOCOL_VERSIONS;
// ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05", "2024-10-07"]Do not read that as TypeScript being behind. The v2 SDK implements the 2026-07-28 spec, but it reaches it through the newer server/discover handshake rather than the legacy initialize call, and it keeps initialize pinned to the older revision for clients that still use it. The practical consequence is the same either way: if you are writing against a specific revision, check what your SDK negotiates rather than what its README claims. Version-pin your tests to the revision you actually got.
So which one should you pick?
The honest answer is the one at the top: use what your team already ships. An MCP server is a small program, and the cost of maintaining it in an unfamiliar language dwarfs a 100 ms startup difference. If that genuinely leaves you a free choice, here is how we would decide.
- Python if you want it working this afternoon. Eight lines, the type hints are the schema, and `uv` handles the interpreter. The cost is a 455 ms start and 28 dependencies.
- TypeScript if the server will also be reachable over HTTP. The v2 SDK's HTTP story is the most complete of the four, and the dependency tree is genuinely small.
- Go if somebody who is not you has to run it. One 9.4 MB binary, no runtime to install, no virtualenv to explain, and a 2 second build.
- Rust if the process starts and stops constantly, or the binary size is a real constraint. 3.5 ms to handshake and 2.6 MB shipped, paid for with a 17 second cold build and the most code of the four.
How to reproduce these numbers
Each server is one file, listed in full above. These are the exact setup commands, with the versions we tested pinned.
# TypeScript, on Node 25.8.1
npm install @modelcontextprotocol/[email protected] [email protected]
node server.js
# Python, on 3.11.15
uv venv --python 3.11
uv pip install "mcp>=2.0.0" # resolved to 2.1.1
.venv/bin/python server.py
# Go, on 1.27.0
go mod init example.com/reverse
go get github.com/modelcontextprotocol/[email protected]
go mod tidy && go build -o reverse-go .
# Rust, on 1.98.0
cargo add [email protected] --features server,transport-io,macros,schemars
cargo add tokio@1 --features rt-multi-thread,macros,io-std
cargo add serde@1 --features derive
cargo add schemars@1
cargo build --releaseTo time a handshake, spawn the server and write one line of JSON-RPC to its stdin, then read one line back. That is the whole measurement.
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"bench","version":"1.0.0"}}}' \
| ./reverse-go
# {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25",
# "capabilities":{"tools":{"listChanged":true}},
# "serverInfo":{"name":"reverse-go","version":"1.0.0"}}}Frequently asked questions
- What is the best language to build an MCP server in?
- The one your team already maintains code in. If you have a free choice: Python is fastest to write, TypeScript has the best HTTP story, Go gives you a single distributable binary, and Rust gives you the fastest startup and the smallest binary. All four SDKs cover the same protocol features.
- Is a Python MCP server too slow?
- Not for a long-lived server. We measured 454.6 ms from spawn to a completed handshake, which is invisible if the process stays up. It matters when a client spawns your server per request, or when a user has many stdio servers and each one adds half a second to app startup.
- Do I need Rust or Go to write a fast MCP server?
- Only if startup time is your bottleneck. Tool execution speed is usually dominated by whatever your tool calls out to, such as a database or an HTTP API. Rust and Go win on process startup, 3.5 ms and 6.5 ms against 120.6 ms for TypeScript, and on shipping a single binary.
- Which MCP SDK has the fewest dependencies?
- TypeScript. A stdio server on
@modelcontextprotocol/server2.0.0 resolves to exactly 3 packages and 14.4 MB. Python'smcp2.1.1 pulls 28 packages includingstarlette,uvicornandcryptography, because the HTTP transport ships in the same distribution as stdio. - Do all the MCP SDKs support the 2026-07-28 spec revision?
- Not over the legacy
initializehandshake. Asked for2026-07-28, onlyrmcp3.1.4 answered with it. The Go and Python SDKs negotiated down to2025-11-25, and the TypeScript SDK pinsinitializeto2025-11-25and reaches the newer revision throughserver/discoverinstead. Check what your SDK negotiates rather than what its README says. - Can I get structured output from a tool in every SDK?
- Yes, but two of them do it for you. Go generates an
outputSchemafrom your typed return struct, and Python generates one from the return annotation, so both emitstructuredContentautomatically. In TypeScript and Rust you declare the output schema explicitly.
About the author
MCPOrbit Team
Engineering, MCPOrbit
The MCPOrbit engineering team builds MCPOrbit, a free desktop client for the Model Context Protocol. It connects to any MCP server so you can browse its tools, call them by hand, and drift-test it across releases.
