<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

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 (Going Further) 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 before, 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 the 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 would read and write the same array. On stdio, "persists across my calls" and "private to me" were the same thing, because each client got its own subprocess. One HTTP server serving many clients splits those into two problems, and the fix has to solve both.

The factory isn't just () => McpServer, it's (ctx) => McpServer, and ctx carries authInfo for whoever made the request. Lesson 11 (Going Further) populates it for real; mock it for now:

// Mock auth: stands in for the real requireBearerAuth from lesson 11 (Going Further),
// 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 authInfo, so the mock and the real thing look identical from the factory. Now key the state by whoever 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"
    }
  ]
}

It persists now, and what persisted is tasksByCaller.get("demo-client") rather than one array everyone shares.

This is a teaching shape, not a production one. The Map lives in one process, so a restart empties it and a second instance behind a load balancer would keep its own. Keying on the caller also gives each person exactly one list, which suits this server because demo://tasks is a single fixed URI, but it is a simplification.

A real server hands out a handle instead: a create-list tool returns an opaque listId, and add-task takes it as an argument. The caller can then have several lists, and the store behind them can be a database any instance reads. That is what create_issue followed by update_issue is doing in every remote MCP server you have already used, and SEP-2567 is where the pattern is written down.

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 the demo-server entry you already have, 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 have been. 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?