Quick Takeaways
What you'll learn in this article
- 1
Claude Desktop or Claude Code installed (for testing)
- 2
@modelcontextprotocol/sdk โ The official MCP TypeScript SDK. Provides server classes, tool registration, and transport handlers.
- 3
zod โ Schema validation for tool parameters. MCP uses JSON Schema, and Zod converts cleanly.
- 4
wrangler โ Cloudflare's CLI for developing, testing, and deploying Workers.
- 5
@cloudflare/workers-types โ TypeScript types for the Workers runtime.
Keep reading for detailed implementation, code examples, and real-world results
The Model Context Protocol just crossed 97 million installs. That is not a typo. In fourteen months, an open standard created by Anthropic went from an experimental curiosity to the foundational plumbing underneath every serious AI integration. Claude, GPT, Gemini, Cursor, Windsurf, VS Code Copilot โ every major AI host speaks MCP now.
But here is the thing most tutorials still get wrong: they teach you to build local MCP servers that communicate over stdio. That was fine in 2025. In 2026, the world has moved to remote MCP servers that run as HTTP services, accessible from anywhere, by any client.
MCP Installs
97M+
Total installs as of March 2026
This tutorial walks you through building a remote MCP server using the Streamable HTTP transport, deploying it to Cloudflare Workers, and connecting it to Claude Desktop and Claude Code. You will build a real bookmark manager that lets AI assistants save, search, and organize bookmarks โ all over the network, no local process required.
If you followed our earlier tutorial on building your first MCP server in TypeScript, consider this the sequel. We go from local to global.
Why Remote MCP Servers Matter
Local MCP servers (stdio transport) require the AI host to spawn a child process on the user's machine. This works for personal tools, but it creates three fundamental problems at scale:
Limitations of Local stdio MCP Servers
| limitation | impact |
|---|---|
| Local Only | 95 |
| No Sharing | 88 |
| No Auth | 82 |
| Process Management | 75 |
| Version Drift | 68 |
First, your tools are trapped on one machine. If you build an MCP server that connects to your company's project database, every developer needs to install and configure it locally. Update the server? Every machine needs updating.
Second, there is no authentication layer. Stdio servers inherit the ambient permissions of the user running the AI host. Fine for personal scripts, terrible for team tools that access shared resources.
Third, process management is fragile. Claude Desktop spawns your server as a subprocess. Crash? Gone. Memory leak? Growing forever. No health checks, no restarts, no observability.
Remote MCP servers solve all three by running as standard HTTP services. One deployment. One URL. Any authorized client connects.
What We Are Building
We will build a Bookmark Manager MCP Server with these tools:
| Tool | Description | Parameters | | ------------------ | ---------------------------------------------- | ----------------------- | | save_bookmark | Save a URL with title, tags, and notes | url, title, tags, notes | | search_bookmarks | Full-text search across saved bookmarks | query, limit | | list_bookmarks | List recent bookmarks with optional tag filter | tag, limit | | delete_bookmark | Remove a bookmark by URL | url |
The server will use Cloudflare D1 (SQLite) for storage, deploy to Cloudflare Workers, and communicate via Streamable HTTP.
Tech Stack Breakdown
| Name | Value |
|---|---|
| Cloudflare Workers | 35 |
| MCP SDK | 30 |
| D1 Database | 20 |
| Wrangler CLI | 15 |
Prerequisites
Before starting, you need:
- Node.js 20+ and npm installed
- A Cloudflare account (free tier works)
- Wrangler CLI: npm install -g wrangler
- Claude Desktop or Claude Code installed (for testing)
- Basic TypeScript knowledge
Verify your setup:
node --version # v20.x or higher wrangler --version # 4.x or higher
If you do not have a Cloudflare account, create one at cloudflare.com. The free tier includes Workers, D1, and everything we need.
Step 1: Scaffold the Project
Create a new directory and initialize the project:
mkdir mcp-bookmark-server cd mcp-bookmark-server npm init -y
Install dependencies:
npm install @modelcontextprotocol/sdk zod npm install -D typescript wrangler @cloudflare/workers-types
Here is what each package does:
- @modelcontextprotocol/sdk โ The official MCP TypeScript SDK. Provides server classes, tool registration, and transport handlers.
- zod โ Schema validation for tool parameters. MCP uses JSON Schema, and Zod converts cleanly.
- wrangler โ Cloudflare's CLI for developing, testing, and deploying Workers.
- @cloudflare/workers-types โ TypeScript types for the Workers runtime.
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"]
}
Estimated Time Per Step
| step | minutes |
|---|---|
| Scaffold | 2 |
| Database | 5 |
| Server Code | 20 |
| Transport | 10 |
| Deploy | 5 |
| Connect | 5 |
Step 2: Configure Wrangler and D1
Create wrangler.toml in the project root:
name = "mcp-bookmark-server" main = "src/index.ts" compatibility_date = "2026-03-01" compatibility_flags = ["nodejs_compat"] [[d1_databases]] binding = "DB" database_name = "mcp-bookmarks" database_id = "" # Filled after creation
Create the D1 database:
wrangler d1 create mcp-bookmarks
This prints a database ID. Copy it into the database_id field in wrangler.toml.
Now create the schema. Make a schema.sql file:
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
tags TEXT DEFAULT '[]',
notes TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_bookmarks_url ON bookmarks(url);
CREATE INDEX IF NOT EXISTS idx_bookmarks_created ON bookmarks(created_at DESC);
Apply the schema:
wrangler d1 execute mcp-bookmarks --local --file=schema.sql
The --local flag applies it to your local development database. When you deploy, run the same command without --local to apply it to production.
Step 3: Define the Environment Interface
Create src/env.ts:
export interface Env {
DB: D1Database
}
This tells TypeScript about the D1 binding we configured in wrangler.toml. Every Worker handler receives this Env object.
Step 4: Build the MCP Server
This is the core of the project. Create src/server.ts:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { Env } from './env.js'
export function createServer(env: Env): McpServer {
const server = new McpServer({
name: 'bookmark-manager',
version: '1.0.0',
})
// Tool: Save a bookmark
server.tool(
'save_bookmark',
'Save a URL as a bookmark with title, tags, and optional notes',
{
url: z.string().url().describe('The URL to bookmark'),
title: z.string().min(1).max(200).describe('Title for the bookmark'),
tags: z.array(z.string()).default([]).describe('Tags for categorization'),
notes: z
.string()
.default('')
.describe('Optional notes about the bookmark'),
},
async ({ url, title, tags, notes }) => {
try {
await env.DB.prepare(
`INSERT INTO bookmarks (url, title, tags, notes)
VALUES (?, ?, ?, ?)
ON CONFLICT(url) DO UPDATE SET
title = excluded.title,
tags = excluded.tags,
notes = excluded.notes,
updated_at = datetime('now')`
)
.bind(url, title, JSON.stringify(tags), notes)
.run()
return {
content: [
{
type: 'text' as const,
text: `Bookmark saved: "${title}" (${url})${
tags.length > 0 ? ` [${tags.join(', ')}]` : ''
}`,
},
],
}
} catch (error: any) {
return {
content: [
{
type: 'text' as const,
text: `Error saving bookmark: ${error.message}`,
},
],
isError: true,
}
}
}
)
// Tool: Search bookmarks
server.tool(
'search_bookmarks',
'Search saved bookmarks by title, URL, tags, or notes',
{
query: z.string().min(1).describe('Search query'),
limit: z
.number()
.int()
.min(1)
.max(50)
.default(10)
.describe('Max results'),
},
async ({ query, limit }) => {
const pattern = `%${query}%`
const { results } = await env.DB.prepare(
`SELECT url, title, tags, notes, created_at
FROM bookmarks
WHERE title LIKE ? OR url LIKE ? OR tags LIKE ? OR notes LIKE ?
ORDER BY updated_at DESC
LIMIT ?`
)
.bind(pattern, pattern, pattern, pattern, limit)
.all()
if (results.length === 0) {
return {
content: [
{
type: 'text' as const,
text: `No bookmarks found matching "${query}"`,
},
],
}
}
const formatted = results
.map((b: any) => {
const tags = JSON.parse(b.tags || '[]')
return `- **${b.title}**\n ${b.url}\n Tags: ${
tags.length > 0 ? tags.join(', ') : 'none'
}\n Saved: ${b.created_at}`
})
.join('\n\n')
return {
content: [
{
type: 'text' as const,
text: `Found ${results.length} bookmark${results.length !== 1 ? 's' : ''}:\n\n${formatted}`,
},
],
}
}
)
// Tool: List recent bookmarks
server.tool(
'list_bookmarks',
'List recent bookmarks, optionally filtered by tag',
{
tag: z.string().optional().describe('Filter by tag'),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(20)
.describe('Max results'),
},
async ({ tag, limit }) => {
let query: string
let params: any[]
if (tag) {
query = `SELECT url, title, tags, notes, created_at
FROM bookmarks
WHERE tags LIKE ?
ORDER BY created_at DESC LIMIT ?`
params = [`%"${tag}"%`, limit]
} else {
query = `SELECT url, title, tags, notes, created_at
FROM bookmarks
ORDER BY created_at DESC LIMIT ?`
params = [limit]
}
const { results } = await env.DB.prepare(query)
.bind(...params)
.all()
if (results.length === 0) {
return {
content: [
{
type: 'text' as const,
text: tag
? `No bookmarks found with tag "${tag}"`
: 'No bookmarks saved yet',
},
],
}
}
const formatted = results
.map((b: any) => {
const tags = JSON.parse(b.tags || '[]')
return `- **${b.title}** โ ${b.url}${
tags.length > 0 ? ` [${tags.join(', ')}]` : ''
}`
})
.join('\n')
return {
content: [
{
type: 'text' as const,
text: `${results.length} bookmark${results.length !== 1 ? 's' : ''}${
tag ? ` tagged "${tag}"` : ''
}:\n\n${formatted}`,
},
],
}
}
)
// Tool: Delete a bookmark
server.tool(
'delete_bookmark',
'Delete a saved bookmark by URL',
{
url: z.string().url().describe('The URL of the bookmark to delete'),
},
async ({ url }) => {
const result = await env.DB.prepare('DELETE FROM bookmarks WHERE url = ?')
.bind(url)
.run()
if (result.meta.changes === 0) {
return {
content: [
{ type: 'text' as const, text: `No bookmark found for: ${url}` },
],
}
}
return {
content: [{ type: 'text' as const, text: `Bookmark deleted: ${url}` }],
}
}
)
return server
}
Let us break down the key patterns here.
Server Creation
McpServer is the high-level class from the official SDK. You give it a name and version โ these are reported to clients during the initialization handshake so they know what they are connecting to.
Tool Registration
Each server.tool() call registers one tool. The four arguments are:
- Name โ The identifier clients use to invoke the tool
- Description โ Natural language description that AI models read to decide when to use the tool
- Schema โ A Zod schema defining the parameters. The SDK converts this to JSON Schema for the MCP protocol.
- Handler โ The async function that executes when the tool is called
Return Format
Every handler returns a content array. Each element has a type (usually "text") and the actual content. If something goes wrong, you set isError: true โ this tells the AI model that the tool call failed, so it can adjust its behavior.
Tool Complexity vs Utility Score
| tool | complexity | utility |
|---|---|---|
| save_bookmark | 35 | 90 |
| search_bookmarks | 40 | 95 |
| list_bookmarks | 30 | 80 |
| delete_bookmark | 15 | 60 |
Step 5: Wire Up the Streamable HTTP Transport
This is where remote MCP servers diverge from local ones. Instead of reading from stdin and writing to stdout, we handle HTTP requests. Create src/index.ts:
import { McpAgent } from 'agents/mcp'
import { createServer } from './server.js'
import type { Env } from './env.js'
export class BookmarkMcpAgent extends McpAgent<Env> {
server = createServer(this.env)
}
export default {
async fetch(
request: Request,
env: Env,
ctx: ExecutionContext
): Promise<Response> {
const url = new URL(request.url)
// Health check endpoint
if (url.pathname === '/health') {
return new Response(
JSON.stringify({ status: 'ok', server: 'bookmark-manager' }),
{
headers: { 'Content-Type': 'application/json' },
}
)
}
// MCP endpoint โ handles all Streamable HTTP communication
if (url.pathname === '/mcp' || url.pathname === '/mcp/message') {
return BookmarkMcpAgent.handle(request, env, ctx)
}
// Root โ server info
if (url.pathname === '/') {
return new Response(
JSON.stringify({
name: 'bookmark-manager',
version: '1.0.0',
protocol: 'MCP',
transport: 'Streamable HTTP',
endpoint: '/mcp',
}),
{ headers: { 'Content-Type': 'application/json' } }
)
}
return new Response('Not Found', { status: 404 })
},
}
How Streamable HTTP Works
With the Streamable HTTP transport, MCP communication happens over standard HTTP:
- Client sends POST to your /mcp endpoint with a JSON-RPC message
- Server processes the message (tool call, list tools, etc.)
- Server responds with either a regular HTTP response or an SSE stream for long-running operations
Typical Request Lifecycle (ms)
| phase | latency |
|---|---|
| Connect | 5 |
| Initialize | 15 |
| List Tools | 8 |
| Tool Call | 45 |
| Response | 10 |
The beauty of this approach is that it uses standard HTTP infrastructure. Load balancers, CDNs, API gateways, monitoring tools โ everything in the HTTP ecosystem works with remote MCP servers out of the box.
Compare this to stdio, where you need the AI host to manage a child process on the same machine. There is no load balancing, no health checks, no horizontal scaling. Streamable HTTP changes the game.
Step 6: Test Locally
Start the development server:
wrangler dev
This starts a local server at http://localhost:8787. Test the health endpoint:
curl http://localhost:8787/health
You should see:
{ "status": "ok", "server": "bookmark-manager" }
To test MCP communication, you can use the MCP Inspector tool:
npx @modelcontextprotocol/inspector http://localhost:8787/mcp
This opens a web UI where you can see your registered tools, call them interactively, and inspect the JSON-RPC messages. It is the best debugging tool in the MCP ecosystem.
Development Loop
Instant
Wrangler hot-reloads on save
Test Each Tool
In the MCP Inspector, test each tool:
Save a bookmark:
{
"url": "https://crashbytes.com",
"title": "CrashBytes - Software Engineering",
"tags": ["tech", "blog"],
"notes": "Great AI content"
}
Search bookmarks:
{
"query": "crashbytes"
}
List bookmarks:
{
"limit": 10
}
Delete a bookmark:
{
"url": "https://crashbytes.com"
}
Verify each tool returns the expected response format.
Step 7: Deploy to Cloudflare Workers
Apply the database schema to production:
wrangler d1 execute mcp-bookmarks --file=schema.sql
Deploy the Worker:
wrangler deploy
Wrangler outputs your deployment URL:
Published mcp-bookmark-server (1.23 sec) https://mcp-bookmark-server.YOUR-SUBDOMAIN.workers.dev
Test the production deployment:
curl https://mcp-bookmark-server.YOUR-SUBDOMAIN.workers.dev/health
Latency Comparison: Local vs Production (ms)
| metric | local | production |
|---|---|---|
| Cold Start | 0 | 12 |
| Tool Call | 5 | 25 |
| DB Query | 2 | 8 |
| Total P95 | 10 | 50 |
Cold starts on Cloudflare Workers are minimal โ typically under 15ms. Your MCP tools will respond in under 50ms at the 95th percentile for simple database operations.
Step 8: Connect to Claude Desktop
Open Claude Desktop's configuration file:
macOS: ~/Library/Application Support/Claude/claude_desktop_config.json Windows: %APPDATA%/Claude/claude_desktop_config.json
Add your remote MCP server:
{
"mcpServers": {
"bookmark-manager": {
"url": "https://mcp-bookmark-server.YOUR-SUBDOMAIN.workers.dev/mcp",
"transport": "streamable-http"
}
}
}
Restart Claude Desktop. You should see the bookmark tools available in the tool picker.
Connect to Claude Code
For Claude Code, add it to your project's .mcp.json:
{
"mcpServers": {
"bookmark-manager": {
"type": "url",
"url": "https://mcp-bookmark-server.YOUR-SUBDOMAIN.workers.dev/mcp"
}
}
}
Or add it globally in ~/.claude/settings.json.
Step 9: Add Authentication
A remote server needs authentication. You should never expose a database-backed MCP server to the open internet without it. Here is a simple bearer token approach.
Update wrangler.toml:
[vars] MCP_AUTH_TOKEN = "" # Set via wrangler secret # Or use secrets (recommended for production): # wrangler secret put MCP_AUTH_TOKEN
Set the secret:
wrangler secret put MCP_AUTH_TOKEN # Enter a strong random token when prompted
Update src/env.ts:
export interface Env {
DB: D1Database
MCP_AUTH_TOKEN: string
}
Add authentication middleware to src/index.ts:
function authenticate(request: Request, env: Env): Response | null {
const auth = request.headers.get('Authorization')
if (!auth || auth !== `Bearer ${env.MCP_AUTH_TOKEN}`) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
return null // Auth passed
}
Then add the check before the MCP handler:
if (url.pathname === '/mcp' || url.pathname === '/mcp/message') {
const authError = authenticate(request, env)
if (authError) return authError
return BookmarkMcpAgent.handle(request, env, ctx)
}
Update your Claude Desktop config to include the token:
{
"mcpServers": {
"bookmark-manager": {
"url": "https://mcp-bookmark-server.YOUR-SUBDOMAIN.workers.dev/mcp",
"transport": "streamable-http",
"headers": {
"Authorization": "Bearer YOUR_TOKEN_HERE"
}
}
}
}
Authentication Options: Effort vs Security
| layer | effort | security |
|---|---|---|
| Bearer Token | 20 | 60 |
| OAuth 2.0 | 70 | 95 |
| mTLS | 85 | 99 |
| No Auth | 0 | 5 |
For production team tools, consider upgrading to OAuth 2.0. The MCP specification includes a full OAuth authorization flow, and Cloudflare Access can act as your identity provider.
Step 10: Add CORS for Browser-Based Clients
If you want browser-based MCP clients to connect (like web-based AI assistants), add CORS headers:
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
// Handle preflight
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders })
}
Add these headers to all your responses. In production, replace the wildcard * with your specific domain.
The Complete Project Structure
Here is what your finished project looks like:
mcp-bookmark-server/ โโโ src/ โ โโโ index.ts # Worker entry point + HTTP routing โ โโโ server.ts # MCP server + tool definitions โ โโโ env.ts # Environment interface โโโ schema.sql # D1 database schema โโโ wrangler.toml # Cloudflare configuration โโโ tsconfig.json # TypeScript configuration โโโ package.json # Dependencies โโโ README.md # Documentation
Code Distribution by Purpose
| Name | Value |
|---|---|
| Tool Logic | 45 |
| Transport/HTTP | 20 |
| Database | 15 |
| Auth | 10 |
| Config | 10 |
That is the entire server. Under 300 lines of TypeScript. Deployed globally on Cloudflare's edge network. Accessible by any MCP client over standard HTTPS.
Remote vs Local: When to Use Each
Not everything needs to be a remote server. Here is a decision framework:
| Use Case | Transport | Why | | --------------------------- | --------------- | ------------------------------ | | Personal automation scripts | stdio | No need for network access | | Team-shared tools | Streamable HTTP | Shared access, central updates | | Database-backed services | Streamable HTTP | Data lives on the server | | File system access | stdio | Needs local filesystem | | Production APIs | Streamable HTTP | Scaling, monitoring, auth | | Quick prototyping | stdio | Faster setup |
stdio vs Streamable HTTP: Feature Comparison
| factor | stdio | remote |
|---|---|---|
| Setup Speed | 95 | 60 |
| Scalability | 20 | 95 |
| Team Sharing | 15 | 90 |
| Security | 40 | 85 |
| Monitoring | 25 | 90 |
| Local Access | 95 | 30 |
What the MCP 2026 Roadmap Means for This
The MCP specification is evolving. The 2026 roadmap includes three changes directly relevant to remote servers:
Stateless protocol evolution. Sessions are being decoupled from the transport layer. This means your Cloudflare Worker will not need to maintain session state โ the protocol itself will handle session continuity through a cookie-like mechanism.
Server discovery via .well-known. A standard metadata format will let clients discover what your server does without connecting to it. You will be able to publish your tool definitions at /.well-known/mcp and clients will auto-discover them.
Horizontal scaling improvements. The current Streamable HTTP transport has friction with load balancers when sessions are stateful. The upcoming changes will make it straightforward to run multiple instances behind a load balancer.
All of these changes are additive. The server you built today will continue working. Future spec releases will make it even better.
Common Pitfalls
After building several remote MCP servers, here are the mistakes I see most often:
Forgetting CORS headers. If any web-based client tries to connect, it will fail silently without CORS. Add the headers from the start even if you think you will only use desktop clients.
Not handling the initialization handshake. The MCP protocol starts with an initialize message. If your server does not respond correctly, clients will timeout and show cryptic errors. The SDK handles this for you โ but if you are building a custom transport, you must implement it yourself.
Returning plain strings instead of content arrays. Every tool response must be wrapped in the { content: [{ type: "text", text: "..." }] } structure. Returning a raw string will crash the client.
Not validating input despite having schemas. Zod validates the parameters, but you should still validate business logic. For example, checking that a URL is actually reachable before bookmarking it, or that a tag name does not contain special characters.
Deploying without testing locally first. Wrangler's local development server is nearly identical to production. Test there first. Debugging a deployed Worker through logs is much slower.
Extending the Server
This bookmark manager is a starting point. Here are ideas for extending it:
- Add resources โ Expose your bookmarks as MCP resources so AI models can read them without calling a tool
- Add prompts โ Create prompt templates like "Summarize my bookmarks about "
- Add pagination โ The list_bookmarks tool should support cursor-based pagination for large collections
- Add analytics โ Track which bookmarks are accessed most and surface trending links
- Add import/export โ Tools to import from browser bookmark files and export to various formats
Each extension follows the same pattern: define a Zod schema, write a handler, register it with the server.
Production Hardening Checklist
Before you send real traffic to your MCP server, walk through this checklist. Each item addresses a failure mode I have seen in production remote MCP deployments.
Rate Limiting
Cloudflare Workers do not include built-in rate limiting on the free plan, but you can implement a simple token bucket using D1 or KV:
async function checkRateLimit(env: Env, clientId: string): Promise<boolean> {
const key = `ratelimit:${clientId}`
const now = Date.now()
const windowMs = 60000 // 1 minute
const maxRequests = 60
const stored = await env.DB.prepare(
'SELECT count, reset_at FROM rate_limits WHERE client_id = ?'
)
.bind(clientId)
.first<{ count: number; reset_at: number }>()
if (!stored || now > stored.reset_at) {
await env.DB.prepare(
`INSERT INTO rate_limits (client_id, count, reset_at)
VALUES (?, 1, ?)
ON CONFLICT(client_id) DO UPDATE SET count = 1, reset_at = ?`
)
.bind(clientId, now + windowMs, now + windowMs)
.run()
return true
}
if (stored.count >= maxRequests) {
return false
}
await env.DB.prepare(
'UPDATE rate_limits SET count = count + 1 WHERE client_id = ?'
)
.bind(clientId)
.run()
return true
}
Add a rate_limits table to your schema:
CREATE TABLE IF NOT EXISTS rate_limits ( client_id TEXT PRIMARY KEY, count INTEGER DEFAULT 0, reset_at INTEGER NOT NULL );
Error Reporting
In production, you want to know when tools fail. Add structured error logging:
function logError(
tool: string,
error: unknown,
context: Record<string, unknown>
) {
console.error(
JSON.stringify({
level: 'error',
tool,
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
...context,
timestamp: new Date().toISOString(),
})
)
}
Cloudflare Workers logs are accessible through wrangler tail or the Cloudflare dashboard. For serious production use, pipe them to a log aggregator like Datadog or Axiom via a Logpush integration.
Input Sanitization
Even with Zod validation, sanitize inputs that touch the database. URLs should be validated for protocol (reject javascript: and data: URLs). Tag names should be stripped of HTML. Notes should have a reasonable length limit:
function sanitizeUrl(url: string): string {
const parsed = new URL(url)
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('Only HTTP and HTTPS URLs are allowed')
}
return parsed.href
}
function sanitizeText(text: string, maxLength: number = 1000): string {
return text
.replace(/<[^>]*>/g, '')
.slice(0, maxLength)
.trim()
}
Health Check Monitoring
Your /health endpoint should check the database connection, not just return a static response:
if (url.pathname === '/health') {
try {
await env.DB.prepare('SELECT 1').first()
return new Response(
JSON.stringify({
status: 'ok',
db: 'connected',
server: 'bookmark-manager',
}),
{ headers: { 'Content-Type': 'application/json' } }
)
} catch {
return new Response(
JSON.stringify({ status: 'degraded', db: 'disconnected' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } }
)
}
}
Point an uptime monitor (UptimeRobot, Better Stack, or your own Zuptik instance) at this endpoint. Set it to check every 60 seconds. If the health check fails, you will know before your users do.
Production Hardening Impact Score
| check | impact |
|---|---|
| Rate Limiting | 90 |
| Error Logging | 85 |
| Input Sanitization | 95 |
| Health Monitoring | 80 |
| Auth Tokens | 98 |
Performance Characteristics
Cloudflare Workers run on the edge โ your MCP server executes in the data center closest to the client. Here is what to expect for a D1-backed server:
| Operation | P50 Latency | P95 Latency | Notes | | ---------------- | ----------- | ----------- | ------------- | | Health check | 2ms | 8ms | No DB call | | Save bookmark | 15ms | 40ms | Single INSERT | | Search bookmarks | 20ms | 55ms | LIKE query | | List bookmarks | 12ms | 35ms | Indexed query | | Delete bookmark | 10ms | 30ms | Single DELETE |
These numbers assume D1 in the same region. If your D1 database is in a different region than the Worker execution, add 20-40ms for cross-region latency.
Latency vs Daily Request Volume (ms)
| requests | p50 | p95 | p99 |
|---|---|---|---|
| 100 | 15 | 40 | 65 |
| 1K | 18 | 45 | 72 |
| 10K | 20 | 50 | 80 |
| 100K | 22 | 55 | 90 |
D1 handles up to 100,000 reads per day on the free tier. For a personal or small team bookmark manager, that is more than enough. If you need more, D1's paid tier scales to millions of operations.
Connecting to the Bigger Picture
Remote MCP servers are the building blocks of the agentic AI infrastructure that is rapidly maturing. As AI assistants move from answering questions to taking actions, they need reliable, authenticated connections to external tools and data.
The 97 million installs milestone is not just a vanity metric. It represents 97 million environments where MCP is the expected way to connect AI to the outside world. If you are building tools that AI should be able to use, MCP is not optional โ it is the standard.
If you are building more complex AI agents, check out our tutorial on building an AI code review agent with the Claude Agent SDK. The Agent SDK and MCP work together โ your agents can use MCP tools as part of their reasoning loops.
Time to Deploy
~47 min
From empty directory to production
Wrapping Up
You now have a production-ready remote MCP server running on Cloudflare Workers. It uses Streamable HTTP transport, D1 for persistent storage, bearer token authentication, and deploys globally in seconds.
The key takeaways:
- Remote MCP servers are the future. Local stdio servers have their place, but team tools and production services should be remote.
- Streamable HTTP is the transport. Standard HTTP means standard infrastructure โ load balancers, monitoring, CDNs all work.
- Cloudflare Workers are ideal hosts. Edge deployment, D1 integration, zero cold start friction, and free tier generosity.
- Authentication is not optional. Any remote server that touches data needs auth from day one.
- The MCP SDK handles the hard parts. Focus on your tool logic, not protocol plumbing.
The code from this tutorial is available on GitHub at github.com/CrashBytes/mcp-bookmark-server. Clone it, deploy it, and start building your own remote MCP tools.
Frequently Asked Questions
Can I use a different database instead of D1?
Absolutely. The MCP server logic is database-agnostic. Replace the D1 calls with any database client โ Postgres via Hyperdrive, Turso, PlanetScale, or even a REST API. The tool handlers are just async functions. As long as they return the content array format, MCP does not care where your data lives.
How do I add more tools after deployment?
Add new server.tool() calls in server.ts, run wrangler deploy, and the tools are immediately available. MCP clients re-fetch the tool list on each connection, so they will see your new tools without any client-side changes.
Can multiple AI assistants connect simultaneously?
Yes. Cloudflare Workers handle concurrent requests natively. Each request is an independent execution โ there is no shared state between requests unless you explicitly use D1 or KV. A hundred Claude Desktop instances can hit your server at the same time without issues.
What happens if Cloudflare Workers go down?
Cloudflare's edge network has a 99.99% uptime SLA. If a specific data center has issues, requests automatically route to the next closest one. Your MCP clients will see brief timeouts (the MCP SDK has built-in retry logic) and recover automatically. This is dramatically more reliable than a local process that dies if your laptop sleeps.
How do I debug tool calls in production?
Use wrangler tail to stream live logs from your deployed Worker. Every console.log and console.error in your code appears in real time. For more sophisticated debugging, add the structured error logging from the Production Hardening section and query your logs in the Cloudflare dashboard.
Is there a size limit on tool responses?
Cloudflare Workers have a 128MB memory limit per request and responses can be up to 100MB. In practice, MCP tool responses should be concise โ a few kilobytes at most. AI models work best with focused, structured responses. If you need to return large datasets, paginate them.
The protocol that hit 97 million installs is not slowing down. Build on it now.
