The Python tutorial on this blog walks through a local MCP server. Useful, but it leaves the hard part for later: the moment your server needs to be reachable from somewhere other than your laptop.
A remote MCP server is a normal HTTP service. It speaks JSON-RPC 2.0 over streamable HTTP (the transport the spec officially recommends, replacing the old SSE transport in revision 2025-11-25). Any MCP client that supports remote servers can talk to it: Claude Code, Claude Desktop, Cursor, Cline, the OpenAI Agents SDK, and a growing list of others. Build it once, ship it once, every client gets it.
This article builds a remote MCP server in TypeScript with the official @modelcontextprotocol/sdk (version 1.29.0 at the time of writing), adds OAuth 2.1, tests it with the MCP Inspector, then publishes it to the official MCP Registry so other developers can discover it.
Prerequisites
- Node.js 20+ (
node --version) npmorpnpm- A Cloudflare account (or any host that runs Node servers, the deploy example uses Workers)
- A GitHub account for the MCP Registry publish step
- About 20 minutes
Step 1: Scaffold the Project
mkdir mcp-remote-demo && cd mcp-remote-demo
npm init -y
npm install @modelcontextprotocol/sdk express zod
npm install -D typescript @types/node @types/express tsx
This pulls the official TypeScript SDK, Express for the HTTP layer, and Zod for input validation (the SDK uses Zod schemas to generate JSON Schema for tool inputs). tsx runs TypeScript directly during development.
A minimal tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"outDir": "dist"
},
"include": ["src"]
}
Step 2: Write the Server
A todo MCP server is more useful than a calculator. It lets the model list, create, and complete tasks, which is the kind of thing real agents actually do.
// src/server.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const todos: Array<{ id: number; title: string; done: boolean }> = [];
let nextId = 1;
const server = new McpServer({
name: 'todo-server',
version: '0.1.0',
});
server.tool(
'add_todo',
'Create a new todo item and return its id',
{ title: z.string().min(1).max(200) },
({ title }) => {
const todo = { id: nextId++, title, done: false };
todos.push(todo);
return {
content: [{ type: 'text', text: `Created todo #${todo.id}: ${todo.title}` }],
};
}
);
server.tool(
'list_todos',
'List all todos, optionally filtered by completion state',
{ onlyOpen: z.boolean().default(false) },
({ onlyOpen }) => {
const items = todos.filter((t) => (onlyOpen ? !t.done : true));
return {
content: [
{
type: 'text',
text: items.length === 0
? 'No todos.'
: items.map((t) => `#${t.id} [${t.done ? 'x' : ' '}] ${t.title}`).join('\n'),
},
],
};
}
);
server.tool(
'complete_todo',
'Mark a todo as done by id',
{ id: z.number().int().positive() },
({ id }) => {
const todo = todos.find((t) => t.id === id);
if (!todo) {
return { content: [{ type: 'text', text: `Todo #${id} not found` }], isError: true };
}
todo.done = true;
return { content: [{ type: 'text', text: `Completed todo #${id}` }] };
}
);
// Expose a read-only resource: a JSON dump of the current todo list
server.resource(
'todo-list',
'todos://all',
async (uri) => ({
contents: [{ uri: uri.href, text: JSON.stringify(todos, null, 2) }],
})
);
const transport = new StdioServerTransport();
await server.connect(transport);
The SDK exposes three primitives, matching the MCP spec:
server.tool()registers a callable function. The model decides when to call it based on the name and the description string.server.resource()exposes read-only data addressed by URI, similar to aGETendpoint.server.prompt()(not used here) registers a reusable prompt template the user can trigger manually.
Tool descriptions matter more than function names. The model picks tools by reading the description. "Mark a todo as done by id" tells the model exactly what the tool does. "Complete" alone leaves the model guessing.
Step 3: Switch to Streamable HTTP
The stdio transport runs the server as a subprocess. Fine for Claude Desktop, useless for the cloud. The spec recommends streamable HTTP for remote servers: a single POST /mcp endpoint that accepts JSON-RPC, plus a GET for server-initiated messages.
// src/http.ts
import express from 'express';
import { randomUUID } from 'node:crypto';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
function buildServer() {
const s = new McpServer({ name: 'todo-server', version: '0.1.0' });
// ... register tools and resources from Step 2 ...
return s;
}
const app = express();
app.use(express.json());
app.post('/mcp', async (req, res) => {
const server = buildServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
res.on('close', () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`MCP server on http://localhost:${port}/mcp`));
A new server per request is the pattern the SDK examples use. The transport is stateful (it tracks the session id) but the server itself is cheap to construct.
Run it:
npx tsx src/http.ts
# MCP server on http://localhost:3000/mcp
Step 4: Test with the MCP Inspector
The Inspector is a web UI that connects to your server and lets you poke at tools, resources, and prompts by hand. Same npm package Anthropic ships to its own team.
npx -y @modelcontextprotocol/inspector
Open the URL it prints (usually http://localhost:5173), set the transport to Streamable HTTP, point it at http://localhost:3000/mcp, and connect. The left panel lists your tools; click add_todo, fill in a title, hit Run, and the response appears on the right.
The Inspector also shows the raw JSON-RPC traffic. If a tool fails, the error message there is usually the fastest way to figure out why.
Step 5: Add OAuth 2.1
Remote servers in production need auth. The spec mandates OAuth 2.1 for HTTP transports, with PKCE for public clients (desktop apps, CLIs) and client credentials for machine-to-machine traffic. The November 2025 revision made this an authentication requirement rather than a recommendation.
The simplest setup uses an external identity provider. Cloudflare Access, Auth0, and Stytch all work; the SDK ships with a reference OAuth client you can adapt. For a single-tenant internal tool, mTLS or a static bearer token is enough. For multi-tenant public servers, OAuth is the only sane option.
A pragmatic middle ground: keep the server private, put it behind a VPN or a service mesh, and skip OAuth entirely. The spec does not require OAuth when the server is unreachable from the public internet.
Step 6: Deploy to Cloudflare Workers
Cloudflare Workers runs the SDK fine. The main change is swapping Express for the Workers fetch handler:
// src/worker.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
export default {
async fetch(request: Request): Promise<Response> {
if (request.method !== 'POST' || new URL(request.url).pathname !== '/mcp') {
return new Response('Not found', { status: 404 });
}
const server = new McpServer({ name: 'todo-server', version: '0.1.0' });
// ... register tools ...
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
return transport.handleRequest(request);
},
};
npm install -D wrangler
npx wrangler deploy
Workers gives you a public HTTPS URL, a global edge, and no cold-start fees on the free tier. The same code runs on Fly.io, Render, Railway, or a plain Node process behind nginx.
Step 7: Connect Claude Code to Your Remote Server
Once deployed, register the server with Claude Code:
claude mcp add --transport http todo-server https://your-worker.workers.dev/mcp
The --transport http flag is required for remote servers; stdio is the default and would try to spawn a local process. Verify with claude mcp list. The server should appear with its name, version, and tool count.
Step 8: Publish to the Official MCP Registry
The MCP Registry is a public directory of MCP servers. Listing there is how users of Claude Desktop, Cursor, and other clients find third-party tools.
Publishing needs a server.json at the root of your repo:
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-11-25/server.schema.json",
"name": "io.github.your-username/todo-server",
"title": "Todo Server",
"description": "A minimal MCP server that manages a todo list",
"version": "0.1.0",
"remotes": [
{
"type": "streamable-http",
"url": "https://your-worker.workers.dev/mcp"
}
]
}
Authentication to the registry uses GitHub OAuth. The publishing guide walks through it step by step. You sign in with your GitHub account, the registry verifies you own the repo, and the listing goes live within a few minutes.
A bot pulls your server.json periodically, so version bumps are just commits. No manual resubmission.
When Remote MCP Is the Wrong Choice
Remote servers are not always the answer.
- A single user, one client, no plans to share. Local stdio is simpler and removes the auth question.
- Latency-sensitive workflows. Streamable HTTP adds a round trip the model did not need before. A local server in the same process is faster.
- Sensitive data that cannot leave your network. Local stdio, or a remote server behind a private VPN, is the only option. Putting PHI in a public Worker is a compliance incident waiting to happen.
- High write throughput. The spec's streamable HTTP transport is request/response. For push-based updates, layer SSE or webhooks on top.
For most production use cases, the calculus is: ship a remote server when more than one client or user needs it, and accept the operational overhead. Ship local stdio otherwise.
Common Pitfalls
Forgetting to handle session cleanup. A long-lived transport leaks memory if you do not call transport.close() on disconnect. The Express example wires this to res.on('close'). Workers handles it automatically.
Loose tool descriptions. A tool called process with description "process data" is unusable. The model will call it for everything and nothing. Be specific: name the inputs, name the outputs, name the side effects.
Versioning the SDK loosely. The TypeScript SDK ships breaking changes between minor versions. Pin it: "@modelcontextprotocol/sdk": "1.29.0". The v2 line is in alpha (2.0.0aN); a stable v2 is targeted for July 2026 per the Python SDK notes, and the TypeScript SDK will follow the same shape.
Skipping the Inspector. Every MCP tutorial skips the Inspector. Then the model fails to call a tool and the developer spends 30 minutes guessing why. Two minutes in the Inspector would have shown the schema mismatch immediately.
Where to Go From Here
A working remote server is a starting point, not an endpoint. Practical follow-ups, in order of value:
- Add OAuth 2.1 properly. The SDK has helpers, the spec has the details, and skipping it limits you to single-tenant deployments.
- Add sampling. The
sampling/createMessagerequest lets your server ask the host LLM to do work, which turns an MCP server into a real agent. The sampling docs cover the protocol. - Version your server with a Server Card. The working group is finalizing the format, but adding one now is forward-compatible.
- Watch the SEP list for protocol changes. The Registry, MCP Apps (interactive UIs), and Tasks (long-running operations) are all in active development.
The protocol is young, the spec is moving, and the ecosystem is shipping weekly. The good news: every server you build today will keep working as the spec evolves. Backward compatibility is one of the things the project takes seriously.