<img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 "> Serving over HTTP

Serving over HTTP

Everything you've built runs over stdio: one client, one process, connected for as long as both stay open. To host one endpoint that many clients can reach at once, you serve the same factory over Streamable HTTP instead. Your tools, resources, and prompts don't change, only how a client reaches them.

Split the factory out

Your server currently lives entirely inside src/stdio-server.ts: the factory and the call that serves it are in one file. HTTP needs to call that same factory too, so pull it out first.

Create src/mcp-server.ts and move everything except the serveStdio call into it, exporting the factory:

import { McpServer } from "@modelcontextprotocol/server";
import * as z from "zod/v4";

export function createServer(): McpServer {
  const server = new McpServer({ name: "demo-server", version: "1.0.0" });
  const tasks: string[] = [];

  server.registerTool(/* ...everything you already registered... */);

  return server;
}

Then reduce src/stdio-server.ts to just the transport:

import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { createServer } from "./mcp-server.js";

serveStdio(createServer);
console.error("demo MCP server running on stdio");

Nothing about the server changed, only which file it lives in.

Add the HTTP entry

Install the pieces createMcpHandler needs on the Node side:

npm install @modelcontextprotocol/express @modelcontextprotocol/node express
npm install -D @types/express

@modelcontextprotocol/express declares express as a peer dependency rather than installing it for you, and its own @types/express is a dev dependency internal to that package, not something consumers get for free. Skip either line and req/res come back untyped in the handler below.

Create src/http-server.ts:

import { createMcpExpressApp } from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { createMcpHandler } from "@modelcontextprotocol/server";
import { createServer } from "./mcp-server.js";

const handler = createMcpHandler(createServer);
const node = toNodeHandler(handler);
const app = createMcpExpressApp();

app.all("/mcp", (req, res) => void node(req, res, req.body));

app.listen(3000, () => {
  console.error("demo MCP server listening on http://127.0.0.1:3000/mcp");
});

createMcpHandler calls your factory once per request, the same isolation guarantee serveStdio gave you once per connection. createMcpExpressApp wires up JSON body parsing and DNS-rebinding protection (Host/Origin validation), but it checks no headers and verifies no tokens of its own, so a bare express() in its place would leave the endpoint unguarded. Lesson 11 covers adding real authorization.

Run it with npx tsx src/http-server.ts and leave it running for the rest of this lesson.

Check it with Inspector

Same fast loop as lesson 3, pointed at a URL instead of a command. In a second terminal:

npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3000/mcp --method tools/call \
  --tool-name add-task --tool-arg title="Buy milk"
{
  "content": [
    {
      "type": "text",
      "text": "Added: Buy milk"
    }
  ]
}

Same tool, same result shape as lesson 3's stdio call, just a URL argument instead of a command to launch. That's the point of the factory split: --cli doesn't care which transport it's talking to.

Notice the state resets

Read the resource back in a separate call:

npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3000/mcp --method resources/read --uri "demo://tasks"
{
  "contents": [
    {
      "uri": "demo://tasks",
      "text": "No tasks yet."
    }
  ]
}

Empty, even though you just added a task. createMcpHandler calls your factory once per HTTP request, so each of those two --cli calls got its own fresh McpServer and, with tasks declared inside createServer, its own fresh tasks array too. This is the isolation guarantee working exactly as intended, it's also the reason your first HTTP request can't remember your second one.

Fix it: key the state by caller

Moving tasks to module scope would make it persist, but it would also make it global: every caller of this server would read and write the same array. In every stdio lesson so far, "persists across my calls" and "private to me" were the same thing, since each client got its own subprocess. One HTTP server serving many clients breaks that, so persisting state and keeping it private are two separate problems, and the fix needs to solve both.

The factory your server passes to createMcpHandler isn't just () => McpServer, it's (ctx) => McpServer, and ctx carries authInfo for whoever made the request. Lesson 11 populates that for real; for now, mock it with a small piece of middleware:

// Mock auth: stands in for the real requireBearerAuth covered in lesson 11,
// which sets req.auth after verifying a bearer token. This hardcodes the same
// shape with a fixed clientId so every request looks like it's from one caller.
// Swap this middleware out for the real thing; nothing below it has to change.
app.use((req, _res, next) => {
  (req as any).auth = { token: "mock", clientId: "demo-client", scopes: [] };
  next();
});

app.all("/mcp", (req, res) => void node(req, res, req.body));

toNodeHandler forwards req.auth into your factory's authInfo automatically, real middleware and this mock work the same way from the factory's point of view. Now key your state by whoever authInfo says is calling, in src/mcp-server.ts:

import type { AuthInfo } from "@modelcontextprotocol/server";

const tasksByCaller = new Map<string, string[]>();

export function createServer({ authInfo }: { authInfo?: AuthInfo }): McpServer {
  const clientId = authInfo?.clientId ?? "anonymous";
  if (!tasksByCaller.has(clientId)) {
    tasksByCaller.set(clientId, []);
  }
  const tasks = tasksByCaller.get(clientId)!;

  const server = new McpServer({ name: "demo-server", version: "1.0.0" });

  server.registerTool(/* ...unchanged... */);

  return server;
}

Restart http-server.ts and repeat the last two commands:

{
  "contents": [
    {
      "uri": "demo://tasks",
      "text": "1. Buy milk"
    }
  ]
}

Persists, the same as flat module scope would have gotten you, but this time it's tasksByCaller.get("demo-client") that persisted, not one array every caller shares. Nothing about registerTool or registerResource changed, the only difference from a flat array is which key you look up before touching tasks.

Note: this still only survives within one running process, and if you ever ran two instances behind a load balancer, each would have its own Map. A real deployment backs tasksByCaller with a database or cache keyed the same way, the per-caller shape stays the same either way.

Connect with your assistant

This is the point of hosting over HTTP: a URL other people's clients can reach, not just yours. Register a second entry for it alongside demo-server from lesson 2, this time pointed at the URL instead of a command.

If you're using GitHub Copilot, add an entry to .vscode/mcp.json:

{
  "servers": {
    "demo-server": {
      "type": "stdio",
      "command": "npx",
      "args": ["tsx", "src/stdio-server.ts"]
    },
    "demo-server-http": {
      "type": "http",
      "url": "http://127.0.0.1:3000/mcp"
    }
  }
}

Then run MCP: List Servers from the Command Palette and start demo-server-http.

If you're using Claude Code, run this in the terminal instead:

claude mcp add --transport http demo-server-http http://127.0.0.1:3000/mcp

With src/http-server.ts still running in the other terminal, ask it to add a task, the same way you did in lesson 3. Same request, same tool, reaching your server over a URL instead of a subprocess your assistant launched itself.

Note: unlike stdio, your assistant doesn't own this server's lifecycle. It connects to whatever's already listening at that URL and doesn't start or stop http-server.ts for you.

Check your understanding

Question 1 of 3

Why did the HTTP task list come back empty?

You call `add-task` over HTTP, then read `demo://tasks` in a second request. It says "No tasks yet." Both requests hit the same running server process. Why is the list empty?