h3-mcp logoh3-mcp

MCP Handler

Mount an MCP server on any H3 route.

defineMcpHandler returns a plain H3 EventHandler. It owns everything below the JSON-RPC layer — transport checks, era dispatch, parameter validation, pagination — and calls your definitions when a client asks for them.

import { H3 } from "h3";
import { defineMcpHandler } from "h3-mcp";

const app = new H3();

app.all(
  "/mcp",
  defineMcpHandler({
    name: "my-server",
    version: "1.0.0",
    title: "My Server",
    instructions: "Ask me about the weather.",
    tools: [/* ... */],
    resources: [/* ... */],
    resourceTemplates: [/* ... */],
    prompts: [/* ... */],
  }),
);

Only name and version are required. Everything else is opt-in, and capabilities are advertised from what you actually declared — define no prompts and the server does not claim the prompts capability.

Read more in All handler options.

#Mount with app.all()

MCP traffic is POST, but the legacy era also uses GET (SSE stream) and DELETE (session teardown) on the same path. Register every method and let the handler answer for itself:

app.all("/mcp", handler); // ✅ handler answers 405 for what it doesn't serve
app.post("/mcp", handler); // ⚠️ legacy GET/DELETE would 404 instead of 405

A modern-only server replies 405 with allow: POST; a dual or legacy server replies 405 with allow: POST, GET, DELETE.

#Server Identity

Identity fields flow into initialize (legacy) and server/discover plus _meta["io.modelcontextprotocol/serverInfo"] (modern):

defineMcpHandler({
  name: "my-server",
  version: "1.0.0",
  title: "Weather Server", // human-friendly display name
  description: "Forecasts and observations",
  websiteUrl: "https://example.com",
  icons: [{ src: "https://example.com/icon.png", mimeType: "image/png", sizes: ["48x48"] }],
  instructions: "Prefer `forecast` over `observations` for future dates.",
});

instructions is the one clients feed to the model, so it is worth writing carefully: describe when to use which tool, not what your server is.

#Dynamic Options

Pass a function instead of an object to resolve options per request — for example, to expose different tools per authenticated user:

defineMcpHandler((event) => ({
  name: "my-server",
  version: "1.0.0",
  tools: getToolsForUser(event),
  resources: getResourcesForTenant(event),
}));

The function runs at most once per request; the result is cached in a WeakMap keyed by the event, so it is garbage-collected with the request. Every option is dynamic, including auth, origin, limits, and cache.

Note

Dynamic options are resolved before the era is dispatched, so the same function serves both eras. Use event.req.headers to branch — the request context is not populated yet.

#Lazy Definitions

tools, resources, resourceTemplates, and prompts also accept functions in the array. Each is called once on first use and cached, so an expensive definition never costs anything until a client lists or calls it:

defineMcpHandler({
  name: "my-server",
  version: "1.0.0",
  tools: [
    // Eager — defined inline
    defineMcpTool({
      name: "ping",
      handler: async () => ({ content: [{ type: "text", text: "pong" }] }),
    }),

    // Lazy — resolved once on first use
    async () => {
      const schema = await loadSchemaFromDB();
      return defineMcpTool({
        name: "query",
        description: "Run a query",
        inputSchema: schema,
        handler: async (args) => ({ content: [{ type: "text", text: "..." }] }),
      });
    },
  ],
});

With static options the resolved array lives for the handler's lifetime. With dynamic options it is cached per event, so a lazy entry runs once per request at most.

#Request Context

Inside any handler, event.context.mcp carries the state of the request in flight — negotiated protocol version, era, client info, the cancellation signal, and the notification helpers:

defineMcpTool({
  name: "whoami",
  handler: (event) => {
    const mcp = event.context.mcp!;
    return {
      content: [
        { type: "text", text: `${mcp.clientInfo?.name ?? "unknown"} on ${mcp.protocolVersion}` },
      ],
    };
  },
});
Read more in Request context reference.

#Multiple Endpoints

Nothing is global — mount as many handlers as you like, each with its own definitions, auth, and era:

app.all("/mcp", defineMcpHandler({ name: "public", version: "1.0.0", tools: publicTools }));
app.all(
  "/mcp/admin",
  defineMcpHandler({
    name: "admin",
    version: "1.0.0",
    tools: adminTools,
    auth: { tokens: [process.env.ADMIN_TOKEN!] },
  }),
);
h3-mcp logo

h3-mcp  MCP servers, built on H3.