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
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?