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
A comparison diagram of one MCP tool implemented in four languages, with cards for TypeScript, Python, Go and Rust showing each one's dependency count, shipped artifact and measured handshake time.

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         no

Two 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.4

What 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 --release

To 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/server 2.0.0 resolves to exactly 3 packages and 14.4 MB. Python's mcp 2.1.1 pulls 28 packages including starlette, uvicorn and cryptography, 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 initialize handshake. Asked for 2026-07-28, only rmcp 3.1.4 answered with it. The Go and Python SDKs negotiated down to 2025-11-25, and the TypeScript SDK pins initialize to 2025-11-25 and reaches the newer revision through server/discover instead. 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 outputSchema from your typed return struct, and Python generates one from the return annotation, so both emit structuredContent automatically. 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.

Share this post

MCPOrbit

Test an MCP server in 60 seconds.

Download MCPOrbit for free — no account, no telemetry. Hear about a server and test it before the curiosity wears off.

macOS 14+ · Apple Silicon & Intel · No account needed