Nitro
An MCP endpoint as a Nitro route.
Nitro builds on H3, so an MCP handler is an ordinary file-based route — and it inherits everything Nitro gives you: filesystem routing, the storage layer, and a build that targets any preset.
#The Route
npm i h3-mcp zodA route file with no method suffix answers every method, which is exactly what MCP needs: POST for JSON-RPC, plus GET/DELETE for the legacy session transport. Name it mcp.post.ts and the legacy era breaks.
import { defineMcpHandler } from "h3-mcp";
import greetTool from "../mcp/tools/greet.ts";
export default defineMcpHandler({
name: "my-server",
version: "1.0.0",
tools: [greetTool],
});That serves /mcp when serverDir is ./server. Definitions are plain objects, so where they live is your call — one file per tool under server/mcp/ keeps the route readable:
import { z } from "zod";
import { defineTool } from "h3-mcp";
export default defineTool({
name: "greet",
description: "Greets someone by name",
inputSchema: z.object({ name: z.string().describe("Who to greet") }),
outputSchema: z.object({ greeting: z.string() }),
handler: async ({ name }) => {
const greeting = `Hello, ${name}!`;
return { content: [{ type: "text", text: greeting }], structuredContent: { greeting } };
},
});#Resources From Server Assets
Nitro bundles everything in assets/ into the server and mounts it on the assets/server storage key, which makes it a natural backing store for a resource — the same code path works in nitro dev and in a built output, on every preset:
import { useStorage } from "nitro/storage";
import { defineResource } from "h3-mcp";
export default defineResource({
name: "notes",
uri: "file:///notes.md",
mimeType: "text/markdown",
handler: async (uri) => {
const text = await useStorage("assets/server").getItem<string>("notes.md");
return { contents: [{ uri: uri.toString(), mimeType: "text/markdown", text: text ?? "" }] };
},
});Note
Server assets resolve from the project root, not from serverDir — assets/notes.md, not server/assets/notes.md. A file in the wrong place reads back as undefined rather than erroring.
#Deploying
nitro build bundles the handler with the rest of your app; nothing in h3-mcp needs a filesystem or a background timer beyond the per-stream keep-alive. Two preset-shaped caveats:
- Serverless: prefer stateless. Legacy sessions live in process memory, so they break as soon as more than one instance serves the endpoint. Serve
era: "modern", or keep session state in a Nitro storage mount keyed by the session id. - Trim the bundle. If you only serve one era, import from
h3-mcp/modernorh3-mcp/legacyand drop the other from the output.