Skip to content
JR Cottam

Notes

Search notes and jump to a post. Press ⌘K (Mac) or Ctrl+K (Windows) to open anytime.

MCP on Bun and Hono with mcp-handler

mcp-handler is built for Next.js, but it's just a Request handler. Here's how I mount it on Bun and Hono without fighting MCP transports.

  • mcp
  • bun
  • hono
  • api

John Ryan Cottam 5 min read

MCP on Bun and Hono with mcp-handler

I’ve been building MCP servers for a while now — wiring Claude Code, Cursor, and other AI clients into real APIs and databases. The protocol itself is straightforward. The runtime quirks are not.

Bun and Hono are my stack of choice when it comes to building APIs. Fast, minimal, deploys to serverless platforms. But MCP’s streaming transport — SSE handshakes, chunked responses, keep-alive handling — breaks in subtle ways depending on your runtime. Bun handles fetch differently than Node. Hono’s response model doesn’t always play nicely with raw streams. You get cryptic disconnections and tools that appear to register but never actually execute.

Vercel’s mcp-handler is officially aimed at Next.js and Nuxt, but under the hood it’s a Request → Response handler. That means you can mount it on Hono without waiting for first-class Bun support in the docs. It handles most of the transport layer — you define what your tools do; it negotiates how they talk to clients. You will still hit proxy timeouts, auth, and deployment edge cases. It just removes the worst of the protocol debugging.

The full setup

Install the packages. Pin the MCP SDK to 1.26.0 or later — earlier versions had a known security issue:

bun add mcp-handler @modelcontextprotocol/sdk@1.26.0 zod

Then wire it up:

import { Hono } from "hono";
import { createMcpHandler } from "mcp-handler";
import { z } from "zod";
import figlet from "figlet";

const app = new Hono();

const handler = createMcpHandler(
  (server) => {
    server.registerTool(
      "createAsciiArt",
      {
        title: "Create ASCII Art",
        description: "Create ASCII art from text using figlet",
        inputSchema: {
          text: z.string(),
        },
      },
      async ({ text }) => {
        const art = figlet.textSync(text);
        return {
          content: [{ type: "text", text: art }],
        };
      },
    );
  },
  {},
  { basePath: "/mcp", maxDuration: 60 },
);

app.all("/mcp/*", async (c) => {
  return await handler(c.req.raw);
});

export default app;

basePath must match where you mount the handler. Here the Hono route and handler config both use /mcp, so the Streamable HTTP endpoint lands at http://localhost:3000/mcp. If you mount elsewhere, change both.

That’s the pattern. Define your tool, mount the handler, ship it.

Full implementation on GitHub →

What the handler actually does

The three arguments tell the full story:

  1. The server callback — where you register tools with registerTool. Each tool gets a name, metadata (title, description, inputSchema), and an async handler. The description is what the model reads to decide when to call it — write this carefully.

  2. Server options — auth callbacks, metadata, session config. Most of the time you leave this empty and add auth before production.

  3. Handler optionsbasePath must match your mount point (see above). maxDuration sets the max execution time in seconds. Raise this for tools that do heavy work. For SSE resumability at scale, you can add redisUrl — not needed for a local hello-world.

Under the hood it manages:

  • The MCP protocol handshake
  • SSE transport vs. HTTP streaming negotiation
  • Message serialization and framing
  • Runtime differences between Bun and Node
  • Concurrent request handling

You still write the tool logic. You just skip implementing the transport yourself.

Write tools that are actually useful

The description field matters more than the implementation. It’s what the model reads to decide whether to call your tool. Vague descriptions mean missed calls or wrong calls.

Weak:

server.registerTool(
  "search",
  { description: "Search for things", inputSchema: { query: z.string() } },
  ...
)

Better:

server.registerTool(
  "searchProducts",
  {
    description:
      "Search the product catalog by name, SKU, or category. Returns matching products with price, inventory, and description.",
    inputSchema: {
      query: z.string(),
      category: z.string().optional(),
    },
  },
  ...
)

The model makes better decisions when it knows exactly what the tool returns and when to use it.

Test before you wire anything up

Don’t go straight to Claude Code or Cursor. Use the MCP Inspector first:

npx @modelcontextprotocol/inspector

Connect to your local server, find your tool, call it with test inputs. Verify the response shape before you involve an AI client. Debugging a broken tool through a chat interface is painful — the inspector makes it obvious where things are failing.

Connect your client

Once the server is running, add it to your MCP client config. Clients that support remote Streamable HTTP can connect with a URL:

{
  "mcpServers": {
    "my-server": {
      "url": "http://localhost:3000/mcp"
    }
  }
}

Stdio-only clients (some desktop apps expect a spawned process, not a URL) need mcp-remote as a bridge:

{
  "mcpServers": {
    "my-server": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "http://localhost:3000/mcp"]
    }
  }
}

Config location varies by client:

  • Cursor: Settings → MCP → Add server
  • Claude Code: ~/.claude.json under mcpServers

Switch the URL to your deployed server URL when you go to production. Add OAuth or token verification before exposing anything sensitive — see the handler’s authorization docs.

When to skip mcp-handler

mcp-handler is the right default when you want HTTP/SSE MCP behind a web framework quickly. Reach for the official MCP TypeScript SDK directly if you need stdio-only local tools, minimal dependencies, or you’re learning the protocol from the ground up. Different tradeoff, same spec.

Why MCP is worth the investment

MCP is the emerging default for AI-tool integration. It’s how agents talk to your APIs, your databases, your internal services — anything that isn’t baked into the model itself.

The value compounds fast. Build a tool once, and it’s available across every MCP-compatible client. Claude Code, Cursor, your custom assistant — they all get access without you writing separate integrations. It’s the same logic that made REST APIs worth standardizing: one interface, many consumers.

For Bun and Hono specifically, mcp-handler is the shortest path I’ve found to a working remote server — fewer transport gotchas, not zero. You still own auth, timeouts, and deployment. But the tool layer stays simple, and that’s usually what matters first.

Resources

Subscribe to my notes

Stay in the loop on what I'm building and thinking about.