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

Serve with Express

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

Mount the handler

createMcpHandler turns a server factory into a web-standard HTTP handler, and toNodeHandler adapts it once to Express's (req, res). createMcpExpressApp is express() with express.json() and DNS rebinding protection already applied.

ts
import { createMcpExpressApp } from '@modelcontextprotocol/express';
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 = createMcpExpressApp();
const node = toNodeHandler(handler);
app.all('/mcp', (req, res) => void node(req, res, req.body));

app is an ordinary Express app 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. createMcpExpressApp 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 = createMcpExpressApp({ 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

createMcpExpressApp installed express.json(), so req.body is the parsed body — passing it as toNodeHandler's third argument keeps the adapter from re-reading the stream Express already consumed. requireBearerAuth verifies the bearer token and attaches the result to req.auth; toNodeHandler forwards it, and handlers read it as ctx.http.authInfo.

ts
import { requireBearerAuth } from '@modelcontextprotocol/express';

const auth = requireBearerAuth({ verifier });
publicApp.all('/mcp', auth, (req, res) => void node(req, res, req.body));

verifier is your token verification. Authorization covers writing one, requiring scopes, and serving the OAuth metadata documents.

Run it and verify

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

ts
app.listen(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: createMcpExpressApp() plus app.all('/mcp', …) over toNodeHandler(createMcpHandler(factory)).
  • A fresh server instance from your factory serves every request.
  • createMcpExpressApp already runs express.json(); pass req.body 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.
  • requireBearerAuth sets req.auth; toNodeHandler forwards it as ctx.http.authInfo.