This is the documentation for the v2 beta — looking for the v1 documentation?
Skip to content

Serve with Fastify

sh
npm install @modelcontextprotocol/server @modelcontextprotocol/fastify @modelcontextprotocol/node fastify

Mount the handler

createMcpHandler turns a server factory into a web-standard HTTP handler, and toNodeHandler adapts it once to Node's (req, res) — a Fastify route hands it request.raw and reply.raw. createMcpFastifyApp is Fastify() with DNS rebinding protection already applied.

ts
import { createMcpFastifyApp } from '@modelcontextprotocol/fastify';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

const handler = createMcpHandler(() => {
    const server = new McpServer({ name: 'notes', version: '1.0.0' });
    server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
        content: [{ type: 'text', text: `Saved: ${text}` }]
    }));
    return server;
});

const app = createMcpFastifyApp();
const node = toNodeHandler(handler);
app.all('/mcp', (request, reply) => node(request.raw, reply.raw, request.body));

app is an ordinary Fastify instance with one route — /mcp answers every MCP request — and nothing is listening yet. The factory runs once per request, so a fresh McpServer serves every call: Serve over HTTP covers that model.

Protect against DNS rebinding

A malicious page can DNS-rebind its own domain to 127.0.0.1 and reach a localhost server as if it were same-origin. createMcpFastifyApp validates the Host and Origin headers against that: with the default 127.0.0.1 bind (and localhost / ::1), a request carrying a non-localhost value gets 403 before your handler runs.

Binding to all interfaces drops that default — name the hosts you serve instead.

ts
const publicApp = createMcpFastifyApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] });

allowedHosts and allowedOrigins take hostnames, port-agnostic. A request without an Origin header always passes, so MCP clients outside a browser are unaffected.

Forward auth and the parsed body

Fastify parses JSON bodies itself, so request.body is already the parsed body — passing it as toNodeHandler's third argument keeps the adapter from re-reading the consumed stream. Auth rides on the Node request: set auth on request.raw and toNodeHandler forwards it, so handlers read it as ctx.http.authInfo.

ts
publicApp.all('/mcp', async (request, reply) => {
    const auth = await verifyToken(request.headers.authorization);
    return node(Object.assign(request.raw, { auth }), reply.raw, request.body);
});

verifyToken is your token verification. Authorization covers verifying bearer tokens and serving the OAuth metadata documents.

Run it and verify

Add the listen line and start the process (npx tsx server.ts).

ts
await app.listen({ port: 3000 });

POST a tools/list request to the endpoint.

sh
curl -s -X POST http://127.0.0.1:3000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

The response is a single SSE message event carrying the tools/list result:

event: message
data: {"result":{"tools":[{"name":"add-note","description":"Append a note","inputSchema":{"type":"object","$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"text":{"type":"string"}},"required":["text"]}}]},"jsonrpc":"2.0","id":1}

Recap

  • One install line, one file: createMcpFastifyApp() plus app.all('/mcp', …) over toNodeHandler(createMcpHandler(factory)).
  • A fresh server instance from your factory serves every request.
  • Fastify already parsed request.body; pass it as toNodeHandler's third argument.
  • The default 127.0.0.1 bind validates Host and Origin; pass allowedHosts when binding to 0.0.0.0.
  • Set auth on the raw Node request; toNodeHandler forwards it as ctx.http.authInfo.