Guide · Runtimes
MCP server with Bun
Bun is a JavaScript runtime built with JavaScriptCore (Safari's engine) that executes TypeScript natively, starts faster than Node.js, and ships a built-in test runner, bundler, and package manager in a single binary. For MCP server development, Bun's main advantages are: no TypeScript compilation step (you run .ts files directly), faster startup time (especially important when monitoring tools probe your server's cold-start response), and a Node.js compatibility layer that lets the MCP SDK and most popular packages work without changes. This guide covers the specifics of running an MCP server on Bun, what differs from Node.js, and how to monitor Bun-based MCP servers in production.
TL;DR
The @modelcontextprotocol/sdk works on Bun without modification — run bun install, write your server in TypeScript, execute with bun run server.ts. Use Bun.serve() for the HTTP transport or stick with Express if you prefer — Bun is compatible with both. The main practical differences from Node.js are: no tsc step needed, bun:test replaces Vitest/Jest for testing, and bun --watch replaces nodemon for development. Monitor Bun MCP servers with AliveMCP the same way as Node.js servers — the protocol is identical, the runtime is transparent to the external probe.
Project setup: Bun vs Node.js for MCP
The initial setup is faster than Node.js because you skip the TypeScript compilation configuration:
# Node.js MCP server setup
npm init -y
npm install @modelcontextprotocol/sdk express zod
npm install -D typescript ts-node @types/node @types/express
# Edit tsconfig.json, configure tsc, add build scripts...
# Bun MCP server setup — that's it
bun init -y
bun add @modelcontextprotocol/sdk zod
# Run directly: bun run server.ts (no tsconfig required for basic usage)
Bun reads TypeScript natively using its built-in parser. You can still add a tsconfig.json for type-checking strictness (Bun respects strict, paths, and other options), but it's not required to execute your code. The bun.lockb file is Bun's binary lock file — commit it instead of package-lock.json.
| Task | Node.js | Bun |
|---|---|---|
| Install packages | npm install | bun install (~3× faster) |
| Run TypeScript server | ts-node server.ts | bun run server.ts |
| Watch mode (dev) | nodemon server.ts | bun --watch server.ts |
| Run tests | vitest or jest | bun test |
| Build for production | tsc && node dist/ | bun run server.ts (no build step) |
| Lock file | package-lock.json | bun.lockb (binary) |
MCP server with Bun.serve()
Bun ships a built-in HTTP server API, Bun.serve(), that is faster than Express for simple routing and requires no additional packages. It supports SSE natively via Response with a ReadableStream:
// server.ts — MCP server using Bun.serve() and SSE transport
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { z } from "zod";
const server = new McpServer({ name: "bun-mcp-server", version: "1.0.0" });
server.tool(
"get_time",
"Get the current server time",
{ timezone: z.string().optional().default("UTC") },
async ({ timezone }) => {
const time = new Date().toLocaleString("en-US", { timeZone: timezone });
return { content: [{ type: "text", text: time }] };
}
);
server.tool(
"query_db",
"Query the SQLite database",
{ sql: z.string(), params: z.array(z.union([z.string(), z.number()])).optional() },
async ({ sql, params }) => {
// Bun ships a built-in SQLite driver — no better-sqlite3 package needed
const db = new Bun.Database("./data.db");
const stmt = db.prepare(sql);
const rows = params ? stmt.all(...params) : stmt.all();
db.close();
return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
}
);
// SSE session store (in-memory; use Redis/KV for multi-process)
const transports = new Map<string, SSEServerTransport>();
Bun.serve({
port: 8080,
async fetch(req) {
const url = new URL(req.url);
// SSE endpoint: agent connects, gets a session ID, and receives events
if (url.pathname === "/sse" && req.method === "GET") {
const sessionId = crypto.randomUUID();
const { readable, writable } = new TransformStream();
const transport = new SSEServerTransport("/messages", writable);
transports.set(sessionId, transport);
await server.connect(transport);
// Clean up on disconnect
req.signal.addEventListener("abort", () => {
transports.delete(sessionId);
transport.close();
});
return new Response(readable, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Session-Id": sessionId,
},
});
}
// Message endpoint: agent POSTs tool calls referencing session ID
if (url.pathname === "/messages" && req.method === "POST") {
const sessionId = req.headers.get("mcp-session-id") ?? "";
const transport = transports.get(sessionId);
if (!transport) return new Response("Session not found", { status: 404 });
await transport.handlePostMessage(req);
return new Response(null, { status: 204 });
}
return new Response("Not found", { status: 404 });
},
});
console.log("MCP server running on http://localhost:8080");
Note Bun.Database: Bun ships a native SQLite binding that is significantly faster than better-sqlite3 and requires no native module compilation. For MCP servers that persist ping history, waitlist data, or tool results, Bun.Database replaces the need for a separate SQLite package.
Node.js compatibility: what works and what doesn't
Bun implements most of Node.js's built-in APIs, but not all. Before migrating an existing Node.js MCP server to Bun, check these common sources of incompatibility:
// These Node.js patterns work identically in Bun:
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { EventEmitter } from "node:events";
import { createServer } from "node:http";
// All standard Node.js modules supported
// These require attention:
// 1. Native addons (N-API / node-gyp): NOT supported in Bun
// better-sqlite3 uses N-API — replace with Bun.Database
// bcrypt uses N-API — replace with Bun's built-in crypto or argon2
// 2. require() CJS interop: works, but prefer ESM imports
// If a package only exports CJS, Bun auto-wraps it (usually fine)
// 3. process.env vs Bun.env: identical — both work
// process.env.MY_VAR === Bun.env.MY_VAR (same store)
// 4. Worker threads: supported in Bun but has differences
// Bun.spawn() is the preferred way to run subprocesses in Bun
The MCP SDK (@modelcontextprotocol/sdk) is pure TypeScript/ESM with no native modules — it runs without changes on Bun. The most common migration issue is packages that depend on node-gyp compiled addons: these must be replaced with Bun-native or pure-JS alternatives.
Testing MCP tools with bun:test
Bun ships a built-in test runner with a Jest-compatible API. Tests run faster than Jest or Vitest because the TypeScript parsing and test runner are both native to the Bun binary:
// server.test.ts — testing MCP tools with bun:test
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
const SERVER_URL = "http://localhost:8080";
describe("MCP tool integration tests", () => {
let client: Client;
beforeAll(async () => {
// Connect MCP client to running server (start server separately or spawn it)
client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
const transport = new SSEClientTransport(new URL(`${SERVER_URL}/sse`));
await client.connect(transport);
});
afterAll(async () => {
await client.close();
});
test("get_time returns a non-empty string for UTC", async () => {
const result = await client.callTool({ name: "get_time", arguments: { timezone: "UTC" } });
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe("text");
expect(result.content[0].text).not.toBe("");
});
test("get_time rejects invalid timezone", async () => {
await expect(
client.callTool({ name: "get_time", arguments: { timezone: "Not/A/Timezone" } })
).rejects.toThrow();
});
test("tools/list returns expected tool names", async () => {
const tools = await client.listTools();
const names = tools.tools.map(t => t.name);
expect(names).toContain("get_time");
expect(names).toContain("query_db");
});
});
# Run tests with live output
bun test
# Watch mode — reruns on file changes (fast due to Bun's native TS parser)
bun test --watch
# Run only tests matching a pattern
bun test --testNamePattern "get_time"
One Bun testing note: bun:test does not support all Jest configuration options. Mocking (jest.mock()) has an equivalent mock() in bun:test, but the API differs slightly. For MCP servers, prefer real integration tests over unit tests with mocks — Bun's fast startup makes spinning up a real server in beforeAll practical in a way that's awkward with slower runtimes.
Production deployment and process management
For production MCP servers running on a VPS or container, Bun works with the same process managers as Node.js:
# pm2 process management (works with Bun)
npm install -g pm2
# ecosystem.config.js
module.exports = {
apps: [{
name: "mcp-server",
script: "bun",
args: "run server.ts",
instances: 1, // SSE servers should be single-instance per endpoint
autorestart: true,
watch: false,
max_memory_restart: "512M",
env: {
NODE_ENV: "production",
PORT: "8080",
},
}],
};
# Start/restart
pm2 start ecosystem.config.js
pm2 restart mcp-server
pm2 logs mcp-server --lines 100
# Docker — multi-stage build with Bun
FROM oven/bun:1 AS builder
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
FROM oven/bun:1-slim AS runner
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
EXPOSE 8080
CMD ["bun", "run", "server.ts"]
The official oven/bun Docker image is Debian-based. The :slim variant removes build tools — appropriate for the production stage since Bun doesn't need a separate compilation step.
One production consideration for SSE-based MCP servers: Bun currently uses a single-threaded event loop (same as Node.js). If your MCP tools make blocking calls (e.g., heavy synchronous computation), they block all concurrent SSE connections. Use Bun.spawn() or Worker threads to offload CPU-bound work.
Monitoring Bun MCP servers
From a monitoring perspective, a Bun-based MCP server is identical to a Node.js server — both expose the same MCP protocol over HTTP/SSE, and external health probes can't tell which runtime is running. The runtime-specific monitoring considerations are:
- Startup time matters for cold-start alerting — Bun starts faster than Node.js (typical difference: 100–300ms less startup time for a simple MCP server). If you set your health check timeout tight (e.g., 2 seconds), Bun servers will pass with more headroom after a restart than Node.js servers. Set your uptime monitoring probe interval and timeout based on what your production process manager guarantees, not the runtime startup time alone.
- bun:test in CI catches protocol regressions — Run integration tests that verify the
initializehandshake andtools/listresponse on every push. A schema regression (accidentally removing a tool) will fail the test before deployment. - Restart loop detection — If pm2 restarts your Bun server repeatedly (e.g., due to an unhandled exception in a tool handler), your MCP server may show as intermittently available from outside. AliveMCP detects this pattern as repeated short outages and shows it on the 90-day uptime graph — much more visible than a log line in pm2 that's easy to miss.
# Verify your Bun MCP server is serving the protocol correctly after restart
curl -X POST http://localhost:8080/messages \
-H "Content-Type: application/json" \
-H "mcp-session-id: test" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"health-check","version":"1.0"}}}'
# Also verify via SSE — connect and read the first event
curl -N http://localhost:8080/sse | head -5
Frequently asked questions
Is Bun faster than Node.js for MCP servers in production?
For I/O-bound MCP servers (the most common case — tools that call external APIs, query a database, or read files), throughput is similar because both runtimes spend most of their time waiting for I/O, not executing JavaScript. Bun's speed advantage is most visible in startup time (100–400ms less for a fresh process start) and package installation (3–10× faster than npm). If your MCP server is CPU-bound (heavy data transformation, JSON manipulation of large datasets), Bun's JavaScriptCore engine may offer a meaningful throughput improvement over Node.js's V8 for some workloads — benchmark your specific tool handlers to confirm.
Do I need to change anything in the MCP SDK for Bun?
No. @modelcontextprotocol/sdk is pure TypeScript/ESM and has no native module dependencies. Run bun add @modelcontextprotocol/sdk and import normally. Bun resolves the package the same way Node.js does. The one consideration: if you use SSEServerTransport, it was designed for Node.js's http.IncomingMessage/http.ServerResponse. With Bun.serve(), use StreamableHTTPServerTransport or wrap Bun's request/response in Express compatibility — many Bun users keep Express for the MCP HTTP layer and use Bun only for the runtime speed, which is a valid approach.
Can I use Bun's built-in SQLite for MCP tool data?
Yes — this is one of the cleanest reasons to use Bun for MCP servers. Bun.Database is a native SQLite binding built into the Bun binary: no npm package, no native compilation, no node-gyp. It implements the same synchronous API as better-sqlite3. If your MCP server needs to store tool results, ping history, or user data in SQLite (common for self-hosted MCP servers), Bun.Database removes a class of dependency problems. The prepare/run/all/get API is essentially identical to better-sqlite3.
How does hot-reload work in Bun for MCP development?
bun --watch server.ts monitors all imported files and restarts the process when any changes. This is faster than nodemon because Bun doesn't need to re-run TypeScript compilation — it re-executes the TypeScript directly. The caveat for MCP servers is that process restart drops all active SSE connections. During development this is fine; in production, use a process manager (pm2 with autorestart: true) rather than --watch. Bun also supports --hot for hot module replacement without a full restart, but this doesn't work reliably with stateful server code.
What's the recommended way to handle unhandled errors in Bun MCP servers?
Add a top-level error handler for both unhandled promise rejections and uncaught exceptions. Bun exits on unhandled rejections by default (same as Node.js 15+). For MCP servers, this means an error in a tool handler that isn't caught will terminate the process and drop all active SSE connections. Wrap tool handlers in try/catch and return structured error responses rather than throwing. For the process level: process.on("unhandledRejection", (err) => { console.error("Unhandled rejection:", err); }) — this logs without crashing. Whether to crash on unhandled rejections is a judgment call; for production servers with a process manager that auto-restarts, crashing and restarting is often cleaner than leaving the server in an unknown state.
Further reading
- MCP server on Cloudflare Workers — edge deployment and V8 isolates
- MCP server on Deno — native TypeScript and permission flags
- MCP server TypeScript — types, SDKs, and code organization
- MCP server unit testing — testing tool handlers in isolation
- MCP server SQLite — local database for tool results and history
- MCP server deployment — VPS, containers, and managed platforms
- AliveMCP — continuous protocol monitoring for MCP servers