The Model Context Protocol (MCP) is rapidly becoming the standard for giving AI models safe, structured access to external tools, APIs, and file systems. Instead of writing messy, custom tool-parsing logic for every new LLM that comes out, you can build a single MCP server. Any MCP-compliant client (like Claude Desktop or custom agents) can connect to it and instantly understand what tools are available.
In this tutorial, we aren't just going to paste code. We are going to build a blazing-fast, production-ready MCP server using Bun and Hono, and we'll break down exactly why each piece works.
Our goal? To give an AI the ability to check real-time weather using the wttr.in API.
The Stack: Why Bun and Hono?
Before we write code, let's understand our tools:
- Bun is a lightning-fast JavaScript runtime. It executes TypeScript natively (no compile step required during dev) and has a highly optimized built-in
fetchclient. - Hono is a lightweight web framework. It’s incredibly fast and has zero dependencies, making it perfect for exposing our MCP server to the web.
- Zod is a schema validation library. We'll use it to strictly define the inputs our AI is allowed to send us.
Let's initialize our project:
mkdir weather-mcp && cd weather-mcp
bun init -y
bun add @modelcontextprotocol/sdk hono zodStep 1: Initializing the MCP Server
We want to keep our core logic separate from our HTTP server. This makes the code easier to test and maintain. Create a file named src/mcp-server.ts.
The first thing we need is the McpServer class from the official SDK. This class acts as the brain of our operation. It holds the registry of all the tools, prompts, and resources we want to expose to the AI.
// src/mcp-server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
export const getServer = () => {
// We initialize the server with a name and version.
// The AI client uses this metadata to know what it's connecting to.
const mcpServer = new McpServer({
name: "weather-mcp",
version: "1.0.0",
});
return mcpServer;
};Step 2: Registering a Tool (Giving the AI Capabilities)
Right now, our server is empty. We need to give the AI a "Tool". In MCP, a tool is simply a function that the AI can choose to call.
To register a tool, we need three things:
- A Name: Unique identifier for the tool.
- A Schema: A description of the tool and the exact arguments it requires. This is critical. The LLM reads this description to decide when to use the tool. We use
Zodto enforce that the AI must send us acityas a string. - An Execution Handler: The actual async JavaScript function that runs when the AI calls the tool.
Let's add our get_weather tool inside getServer:
mcpServer.registerTool(
"get_weather",
{
description: "Gets the current weather for a specific city.",
inputSchema: {
// Zod ensures the AI provides a valid string.
// The .describe() method is passed to the AI so it understands the argument!
city: z.string().describe("The name of the city, e.g., 'London', 'Tokyo'"),
},
},
async ({ city }) => {
try {
// We use the wttr.in API.
// The format=3 query parameter tells the API to return a simple text string.
const response = await fetch(`https://wttr.in/${encodeURIComponent(city)}?format=3`);
if (!response.ok) {
throw new Error("Failed to fetch weather data.");
}
const weatherData = await response.text();
// MCP tools must return an object with a 'content' array.
// This array can contain text, images, or even UI elements.
return {
content: [
{
type: "text",
text: weatherData.trim(), // e.g., "London: ☀️ +22°C"
},
],
};
} catch (error: any) {
// If an API fails, we don't want the whole server to crash.
// We return an isError flag so the AI knows its tool call failed.
return {
isError: true,
content: [{ type: "text", text: `Error: ${error.message}` }]
};
}
},
);Step 3: Understanding the Transport Layer
We have an MCP server, but how does the AI actually talk to it?
MCP is agnostic to how data travels. It can communicate over Standard Input/Output (stdio) for local scripts, or it can communicate over the network using HTTP and Server-Sent Events (SSE).
Because we want to deploy this server to the cloud (like Render or Railway), we will use HTTP. The SDK provides a class called WebStandardStreamableHTTPServerTransport. Its job is to take raw incoming HTTP requests, decode the MCP protocol messages inside them, pass them to our McpServer, and format the response back into HTTP.
Step 4: Exposing the Server via Hono
Now we'll create our src/index.ts file to spin up the Hono web server and wire it to our transport layer.
// src/index.ts
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { getServer } from "./mcp-server.ts";
const app = new Hono();
// We add standard middleware.
// CORS is important if your MCP client is a web-based chat interface.
app.use(logger());
app.use(
"*",
cors({
origin: "*",
allowMethods: ["GET", "POST", "OPTIONS"],
allowHeaders: ["Content-Type", "mcp-session-id", "Last-Event-ID", "mcp-protocol-version"],
exposeHeaders: ["mcp-session-id", "mcp-protocol-version"],
}),
);
// We catch all requests to /mcp
app.all("/mcp", async (c) => {
try {
// 1. Create a new transport instance for this request
const transport = new WebStandardStreamableHTTPServerTransport();
// 2. Get our configured MCP Server
const server = getServer();
// 3. Connect the server to the transport layer
await server.connect(transport);
// 4. Pass the raw incoming HTTP request to the transport layer.
// The transport parses the MCP message, executes our get_weather tool,
// and returns the HTTP response automatically!
return await transport.handleRequest(c.req.raw);
} catch (error: any) {
console.error("[MCP Error]", error);
return c.json({ error: "Invalid MCP Protocol invocation" }, 400);
}
});
// We read the PORT environment variable.
// Cloud providers dynamically assign ports, so this ensures we don't crash in production.
const PORT = parseInt(process.env.PORT || "3000", 10);
console.log(`MCP Server running on http://localhost:${PORT}/mcp`);
export default {
port: PORT,
fetch: app.fetch,
};Step 5: Run and Test
Update your package.json to include a dev script:
{
"scripts": {
"dev": "bun --watch src/index.ts"
}
}Fire up your terminal and run:
bun run devConclusion
You just built a production-ready MCP server! Let's recap what makes this architecture so powerful:
- Zod Schemas act as a contract. The LLM knows exactly what data to provide, and your code never has to deal with badly formatted tool arguments.
- The Transport Layer abstracts away the complex MCP message parsing. You just hand it an HTTP request, and it does the rest.
- By using Bun and Hono, the memory footprint is tiny, making it incredibly cheap and fast to host on edge networks or container platforms.
You can now point any MCP-compliant AI client at http://localhost:3000/mcp, and watch as it autonomously fetches the weather for you!
