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