Getting Started
Get started with h3-mcp.
Warning
h3-mcp is still evolving and may introduce breaking changes.
#Overview
h3-mcp turns any H3 app into a Model Context Protocol server. It implements MCP as a self-contained JSON-RPC 2.0 layer — there is no MCP SDK dependency, and the whole library is around 16kb gzip, or 10kb if you serve a single protocol era.
You describe your server declaratively — tools, resources, prompts — and defineMcpHandler gives you back an ordinary H3 event handler you can mount anywhere:
- Every JSON-RPC method of the current spec is implemented for you, on both protocol eras.
- All client-supplied input (tool arguments, prompt arguments, resource URIs, cursors, headers) is validated at the transport boundary before it reaches your handler.
- Security defaults —
Originchecks, body-size andContent-Typelimits, private cache scopes — are on before you configure anything.
#Quick Start
Install h3 and h3-mcp as dependencies, plus a schema library if you want your tool arguments and results checked at runtime — Zod, Valibot and ArkType all work, and the plain JSON Schema tab below needs none of them:
npm i h3 h3-mcp zodCreate a server entry:
import { H3, serve } from "h3";
import { z } from "zod";
import { defineMcpHandler, defineTool } from "h3-mcp";
const app = new H3();
app.all(
"/mcp",
defineMcpHandler({
name: "my-server",
version: "1.0.0",
tools: [
defineTool({
name: "hello",
description: "Say hello",
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 } };
},
}),
],
}),
);
serve(app, { port: 3000 });Then run it with your favorite runtime:
node --watch ./server.tsYour MCP endpoint is live on http://localhost:3000/mcp. Ask it what it can do:
curl -X POST http://localhost:3000/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-H 'mcp-protocol-version: 2026-07-28' \
-H 'mcp-method: tools/list' \
-d '{
"jsonrpc": "2.0", "id": 1,
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}'#What Happened?
We mounted a single handler with app.all():
app.all("/mcp", defineMcpHandler({/* ... */}));all matters: MCP is POST-driven, but the legacy era also uses GET (for the SSE stream) and DELETE (to tear down a session) on the same path. defineMcpHandler answers 405 itself for methods it does not serve, so it is safe to hand it every method.
Inside, we declared one tool with defineTool:
defineTool({
name: "hello",
inputSchema: {/* JSON Schema, or a Zod / Valibot / ArkType schema */},
outputSchema: {/* same, for what the handler returns */},
handler: async ({ name }) => ({/* content + structuredContent */}),
});defineTool is an identity function — it returns the definition unchanged and exists purely so TypeScript can infer your handler's argument type from inputSchema. The same holds for defineResource, defineResourceTemplate, and definePrompt.
Both schemas are enforced, not just published: arguments are validated before the handler runs, and the structuredContent it returns is validated before it reaches the client. A plain JSON Schema has no validator to run, so it is advertised only — that is the whole difference between the tabs above.
That's the whole server. tools/list, tools/call, server/discover, initialize, pagination, and input validation are all handled for you.
#Try the Playground
The same server, live — pick an argument, call the tool, watch the JSON-RPC go by:
The repository ships it, so you can run the identical thing locally. It exercises every feature — tools, resources, templates, prompts, sessions, auth, streaming, caching, MRTR:
git clone https://github.com/h3js/mcp
cd mcp && pnpm install
pnpm playIt serves a small web UI at http://localhost:6274/ and three endpoints — /mcp (dual), /mcp/modern, and /mcp/legacy — one per entrypoint, so you can compare eras against the same definitions.
#Connect a Client
Any MCP client works. For a local stdio-only client, bridge with mcp-remote:
{
"servers": {
"my-server": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:3000/mcp"]
}
}
}