---
url: https://fulmine.sndesign.it/examples/mcp.md
description: >-
  A Model Context Protocol server with the official SDK on the Streamable HTTP
  transport, a session per client, served by fulmine.js.
---

# An MCP server on Express

The official MCP SDK works unchanged. The page shows the shape of the Streamable HTTP transport and the one thing uWS does differently with a stream that stays open.

From a clone of the repository:

```sh
cd examples && npm install
node mcp.js
```

```js
// An MCP server on the Streamable HTTP transport, with the official SDK unchanged. Express serves
// this the same way, so what is here is the shape: the POST answer leaves as an event stream and the
// GET is a response that stays open for as long as the session does.
//
//   node mcp.js   ->  http://localhost:3000/mcp
//   npx @modelcontextprotocol/inspector   ->  point it at that url, transport "Streamable HTTP"
//
// The one thing to know that Express does not ask you to: uWS does not put the head of a response on
// the wire until its first body byte, so the GET stream reads as unanswered until the server writes
// into it. The SDK's keep-alive comment frame opens it, every 15 seconds unless keepAliveMs says
// otherwise, and a notification pushed before that still arrives.
const express = require("fulmine.js"); // instead of require("express")
const { randomUUID } = require("node:crypto");
const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
const { StreamableHTTPServerTransport } = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
const { z } = require("zod");

const app = express();

// One transport per session, which is what the SDK asks for: the id it writes into the
// mcp-session-id header is how a client comes back to its own.
const sessions = new Map();

/** A server with one tool, on a transport that registers itself here once the session opens. */
function openSession() {
    const server = new McpServer({ name: "fulmine-example", version: "1.0.0" });

    server.registerTool(
        "add",
        { description: "sum two integers", inputSchema: { a: z.number().int(), b: z.number().int() } },
        ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] })
    );

    const transport = new StreamableHTTPServerTransport({
        sessionIdGenerator: () => randomUUID(),
        onsessioninitialized: (id) => sessions.set(id, transport),
        onsessionclosed: (id) => void sessions.delete(id)
    });
    server.connect(transport);
    return transport;
}

/** The session this request belongs to, or a new one when it is the initialize that starts it. */
function sessionOf(req) {
    const id = req.get("mcp-session-id");
    if (id === undefined) {
        return req.method === "POST" ? openSession() : undefined;
    }
    return sessions.get(id);
}

// express.json() parses the body and the transport is handed it, which is the mounting the SDK's own
// example shows. Leaving it out works too: the transport reads the stream itself.
app.all("/mcp", express.json(), (req, res) => {
    const transport = sessionOf(req);
    if (!transport) {
        res.status(404).json({
            jsonrpc: "2.0",
            error: { code: -32001, message: "Session not found" },
            id: null
        });
        return;
    }
    transport.handleRequest(req, res, req.body);
});

app.listen(3000, () => console.log("http://localhost:3000/mcp"));

```

See also: [Compatibility](/compatibility).
