Build an MCP Server in TypeScript with Bun: 60ms Cold Start (2026)
A TypeScript MCP server on Bun boots in ~60 ms vs ~140 ms on Node.js — a 2.3× cold-start edge for hosted, on-demand servers. The official @modelcontextprotocol/sdk runs on Bun without patches: stdio for Claude Desktop, HTTP+SSE for hosted services, single-file deploys via bun build --compile. Three Bun caveats: (1) fetch keep-alive defaults differ, (2) AbortController on streams needs an explicit listener, (3) read process.stdin directly — Bun's readline shim drops the JSON-RPC framing MCP requires.
When to choose TypeScript over Python
The Python MCP guide covers FastAPI servers. Pick TypeScript on Bun when:
- Your team is full-stack TS. A Next.js shop already has Zod, tsconfig, and TS CI. Adding Python doubles the toolchain.
- You need Bun's speed. Startup beats Node by ~80 ms and Python by ~250 ms — user-visible for MCPs spawned per-conversation by Claude Desktop.
- You want to reuse Next.js types. Share a
types/package between MCP tools and web client. No drift between OpenAPI specs and TS interfaces.
Python wins when tools wrap pandas, scikit-learn, or libraries where the Python ecosystem is materially better. Otherwise TS on Bun is the 2026 default.
Minimal stdio server
The simplest MCP server registers one tool and reads JSON-RPC over stdin/stdout. Claude Desktop spawns the process, pipes requests in, and reads responses out.
// server.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new Server(
{ name: "claudeguide-demo", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
const EchoArgs = z.object({ message: z.string().min(1).max(1000) });
server.setRequestHandler("tools/list", async () => ({
tools: [
{
name: "echo",
description: "Echo back the input message.",
inputSchema: {
type: "object",
properties: { message: { type: "string" } },
required: ["message"],
},
},
],
}));
server.setRequestHandler("tools/call", async (req) => {
if (req.params.name !== "echo") throw new Error("unknown tool");
const { message } = EchoArgs.parse(req.params.arguments);
return { content: [{ type: "text", text: `you said: ${message}` }] };
});
const transport = new StdioServerTransport();
await server.connect(transport);
Run it with bun run server.ts. Zod validates inputs at the boundary — never trust req.params.arguments directly.
HTTP transport for hosted servers
Stdio is local-only. For shared, hosted MCP servers (multi-tenant, behind a URL), use the HTTP+SSE transport. Hono is the right framework on Bun: smaller than Express, faster routing, native Request/Response objects.
// http-server.ts
import { Hono } from "hono";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
const app = new Hono();
const mcp = new Server(
{ name: "claudeguide-http", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// register tools/list and tools/call as before...
app.get("/sse", async (c) => {
const transport = new SSEServerTransport("/messages", c.res);
await mcp.connect(transport);
return c.body(null);
});
app.post("/messages", async (c) => {
const body = await c.req.json();
// SSEServerTransport routes the message to the active session
return c.json({ ok: true });
});
export default { port: 3000, fetch: app.fetch };
Express works too, but you pay ~15 ms extra per request for the middleware chain on Bun. Hono is built around Request/Response primitives that Bun returns natively.
Auth: bearer token middleware
Hosted MCP servers need auth. The cleanest pattern is a Hono middleware that validates a bearer token against an env var.
// auth.ts
import type { MiddlewareHandler } from "hono";
const TOKEN = process.env.MCP_TOKEN;
if (!TOKEN || TOKEN.length < 32) {
throw new Error("MCP_TOKEN must be set and >= 32 chars");
}
export const bearerAuth: MiddlewareHandler = async (c, next) => {
const header = c.req.header("authorization") ?? "";
const [scheme, token] = header.split(" ");
if (scheme !== "Bearer" || token !== TOKEN) {
return c.json({ error: "unauthorized" }, 401);
}
await next();
};
// in http-server.ts:
// app.use("/sse", bearerAuth);
// app.use("/messages", bearerAuth);
Validate the env var at startup, not per-request. A missing or short token should crash the process before it accepts a single connection. For multi-user setups, swap the static comparison for a database lookup keyed on the token hash.
Mid-article aside: running an MCP server still costs API tokens upstream. If you're shipping this to production, the Cost Optimization Masterclass walks through the prompt-cache and batch-API tactics that cut Claude bills 60–80% — the same playbook used to keep this project under a 90만원/month ceiling.
Streaming resources: SSE "tail logs" tool
MCP supports streaming responses via Server-Sent Events. Useful for long-running tools — log tails, build watchers, agent traces.
// tail-logs-tool.ts
import { z } from "zod";
const TailArgs = z.object({
file: z.string(),
lines: z.number().int().min(1).max(10_000).default(100),
});
server.setRequestHandler("tools/call", async (req) => {
if (req.params.name !== "tail_logs") throw new Error("unknown tool");
const { file, lines } = TailArgs.parse(req.params.arguments);
const ac = new AbortController();
const stream = new ReadableStream({
async start(controller) {
const proc = Bun.spawn(["tail", "-n", String(lines), "-f", file], {
stdout: "pipe",
});
// CRITICAL: wire abort to kill the child
ac.signal.addEventListener("abort", () => proc.kill());
const reader = proc.stdout.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
controller.enqueue(value);
}
controller.close();
},
cancel() { ac.abort(); },
});
return { content: [{ type: "resource", resource: { uri: `tail://${file}`, mimeType: "text/plain", text: await new Response(stream).text() } }] };
});
The AbortController listener is the Bun-specific bit — without it, killing the SSE connection leaves the tail -f orphaned.
Local testing: mcp-inspector
Don't debug MCP by reading raw JSON-RPC. Use the official inspector UI:
bunx @modelcontextprotocol/inspector bun run server.ts
It opens a browser at localhost:5173, lists your registered tools, lets you call each with form-validated arguments, and shows the JSON wire traffic. Catches schema drift and validation bugs in seconds.
Deploy: Fly.io single-region
A fly.toml for a Bun MCP server is ~30 lines. Single-region deploy is fine — MCP servers are spawned on-demand and don't need global edge.
# fly.toml
app = "claudeguide-mcp"
primary_region = "nrt"
[build]
dockerfile = "Dockerfile"
[env]
PORT = "3000"
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 0
[[vm]]
size = "shared-cpu-1x"
memory = "256mb"
Dockerfile is a 4-liner: FROM oven/bun:1, copy, bun install --production, CMD ["bun", "run", "http-server.ts"]. Set MCP_TOKEN via fly secrets set. Railway is a turnkey alternative — push to GitHub, Railway autodetects Bun, sets the port, and gives you a *.up.railway.app URL.
Wiring into Claude Code and Claude Desktop
For Claude Code, one command:
claude mcp add claudeguide-demo bun run /abs/path/to/server.ts
For Claude Desktop, edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS):
{
"mcpServers": {
"claudeguide-demo": {
"command": "bun",
"args": ["run", "/abs/path/to/server.ts"]
}
}
}
Restart Claude Desktop. The tool appears in the MCP indicator. For hosted HTTP servers, use the URL form with Authorization header config — both Claude Code and Desktop support it as of 2026.
Bun-specific gotchas
fetchdefaults differ. Bun'sfetchkeeps connections alive aggressively. See the Bun runtime article — for MCP, setkeepalive: falsewhen calling external APIs from a tool if you see stuck connections.AbortControlleron streams. When the MCP client disconnects, wiresignal.addEventListener("abort", ...)to clean up child processes. Bun does not auto-kill subprocesses spawned inside aReadableStream.- stdin handling. Use
process.stdindirectly — not Bun'sreadlinepolyfill. Readline buffers by line, but MCP framing is length-prefixed JSON-RPC. The SDK'sStdioServerTransporthandles this correctly; don't substitute it.
Bun vs Node MCP server
| Metric | Bun 1.x | Node 22 LTS |
|---|---|---|
| Cold start (stdio server) | ~60 ms | ~140 ms |
| RSS memory (idle) | ~38 MB | ~52 MB |
| Single-file deploy | bun build --compile (one binary) |
pkg / nexe (works, finicky) |
@modelcontextprotocol/sdk compat |
Full | Full |
| Hot reload in dev | bun --watch (built-in) |
tsx watch / nodemon |
Bun wins on every axis that matters for spawn-per-conversation MCP servers. Node still wins if you depend on a native module that hasn't been ported (rare in 2026 — most have been).
Frequently Asked Questions
Stdio or HTTP — which transport?
Stdio for local, HTTP for shared. Stdio is zero-config: Claude Desktop spawns the process, no auth, no network. Use it for personal tools. HTTP+SSE is for multi-user hosted servers — when several people connect to the same MCP, or when the server needs persistent state. HTTP needs bearer auth and a deploy target; stdio just needs the binary on disk.
Does Bun support all of @modelcontextprotocol/sdk?
Yes, as of SDK v1.x and Bun 1.x (May 2026). All transports — stdio, SSE, experimental WebSocket — work without polyfills. The only edge case is pkg-based bundling helpers in the SDK examples; use bun build --compile instead.
How do I authenticate users on hosted MCP?
Bearer tokens via Hono middleware (shown above) are the path of least resistance. For multi-tenant SaaS, store hashed tokens in a database (Postgres, SQLite via bun:sqlite) and look up per-request. OAuth 2.1 device flow is the emerging standard — added to the MCP spec in early 2026 — but bearer tokens cover 90% of real deployments.
Can I share types between MCP server and Next.js client?
Yes, and you should. Put tool argument schemas in a shared package — e.g., packages/mcp-types/ — exporting Zod schemas. The MCP server imports them for runtime validation; the Next.js client imports inferred types via z.infer<typeof EchoArgs>. One source of truth. This is the single biggest reason TS shops pick TS over Python.
What's the rate limit?
No protocol-level rate limit in MCP — you enforce it server-side. Drop hono-rate-limiter in front of /messages. A reasonable start: 60 calls/minute per token for tools, unlimited for tools/list. Tune based on tool cost — if a tool wraps a Claude API call, the upstream cost matters more than request count.
Related guides:
- Build an MCP Server with FastAPI in Python — the Python companion to this article
- Claude API on Bun runtime: setup and benchmarks — Bun-specific quirks for Claude API clients
- Claude Code MCP 한국어 가이드 — 한국어 사용자를 위한 MCP 통합 가이드
Tested against @modelcontextprotocol/sdk@1.x and Bun 1.x as of May 2026. If the SDK ships a v2 with breaking changes, this article will be updated.