MCP Servers on Cloudflare Workers: OAuth2 + KV Token Storage
MCP servers need to live somewhere. If you're building tools that proxy API calls to third-party services — and most useful MCP tools do exactly that — you need authentication, token storage, and reli
MCP Servers on Cloudflare Workers: OAuth2 + KV Token Storage
MCP servers need to live somewhere. If you're building tools that proxy API calls to third-party services — and most useful MCP tools do exactly that — you need authentication, token storage, and reliable request handling. Cloudflare Workers turn out to be a surprisingly good fit for this, with some caveats.
I built the Polar MCP server on Workers. Here's everything I learned.
Why Workers for MCP
The pitch is simple: MCP servers are mostly stateless request handlers. A tool call comes in, you authenticate against a third-party API, make some requests, return the results. That's exactly what edge functions are designed for.
The specifics that matter:
- Cold start times under 5ms. MCP tool calls need to feel instant. Workers deliver.
- Global distribution. If your users are everywhere, their MCP requests hit the nearest Cloudflare edge. Less latency.
- KV storage for tokens. You need to store OAuth tokens somewhere. KV is built-in, fast, and cheap.
- Free tier is generous. 100,000 requests per day. For an MCP server that a handful of developers use, you might never pay.
The Architecture
MCP Client (Claude, etc.)
↓
Cloudflare Worker (MCP Server)
↓ OAuth2 check
Cloudflare KV (Token Storage)
↓ Authenticated request
Third-Party API (Polar, GitHub, etc.)
↓ Response
Back to MCP Client
The Worker handles three responsibilities: MCP protocol compliance (parsing tool calls, returning structured responses), OAuth2 token management (storing, refreshing, validating tokens), and API proxying (making authenticated requests to the third-party service).
The OAuth2 Flow on Workers
This is where it gets interesting. OAuth2 requires a callback URL — the service redirects back to you after the user authorises. On Workers, this is just another route:
// Simplified OAuth2 flow
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Step 1: Redirect user to authorise
if (url.pathname === '/oauth/authorize') {
const state = crypto.randomUUID();
await env.KV.put(`oauth_state:${state}`, 'pending', {
expirationTtl: 600, // 10 minutes
});
const authUrl = new URL('https://api.polar.sh/oauth2/authorize');
authUrl.searchParams.set('client_id', env.POLAR_CLIENT_ID);
authUrl.searchParams.set('redirect_uri', `${env.BASE_URL}/oauth/callback`);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('scope', 'read write');
return Response.redirect(authUrl.toString());
}
// Step 2: Handle callback
if (url.pathname === '/oauth/callback') {
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
// Verify state
const storedState = await env.KV.get(`oauth_state:${state}`);
if (!storedState) {
return new Response('Invalid state', { status: 400 });
}
// Exchange code for token
const tokenResponse = await fetch('https://api.polar.sh/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: env.POLAR_CLIENT_ID,
client_secret: env.POLAR_CLIENT_SECRET,
redirect_uri: `${env.BASE_URL}/oauth/callback`,
}),
});
const tokens = await tokenResponse.json();
// Store tokens in KV
await env.KV.put(`tokens:${tokens.user_id}`, JSON.stringify({
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: Date.now() + (tokens.expires_in * 1000),
}));
return new Response('Authorised. You can close this window.');
}
// Step 3: Handle MCP requests
if (url.pathname === '/mcp') {
return handleMcpRequest(request, env);
}
}
};
The critical detail is the state parameter. Without it, you're vulnerable to CSRF attacks. Generate a random state, store it in KV with a short TTL, verify it on callback. Standard OAuth2, but easy to forget when you're moving fast.
KV Token Storage Patterns
Cloudflare KV is eventually consistent with a global propagation delay of about 60 seconds. For token storage, this is fine — tokens are long-lived and don't change frequently. But there are patterns to get right:
interface StoredTokens {
access_token: string;
refresh_token: string;
expires_at: number;
}
async function getValidToken(userId: string, env: Env): Promise<string> {
const stored = await env.KV.get(`tokens:${userId}`, 'json') as StoredTokens;
if (!stored) {
throw new Error('User not authorised');
}
// Token still valid — use it
if (stored.expires_at > Date.now() + 60000) { // 1 min buffer
return stored.access_token;
}
// Token expired or about to — refresh it
const refreshed = await refreshToken(stored.refresh_token, env);
await env.KV.put(`tokens:${userId}`, JSON.stringify({
access_token: refreshed.access_token,
refresh_token: refreshed.refresh_token,
expires_at: Date.now() + (refreshed.expires_in * 1000),
}));
return refreshed.access_token;
}
The 60-second buffer before expiry is important. If a token expires in 30 seconds and you start a series of API calls, the last one might fail. Refresh proactively.
One gotcha with KV: there's a write limit of 1,000 writes per second per namespace. For an MCP server serving a handful of users, this is irrelevant. But if you're building something with thousands of concurrent users refreshing tokens, you'll need a different approach. Durable Objects would be the Cloudflare-native answer.
MCP Tool Handlers
The actual MCP tool implementations are straightforward once authentication is sorted:
const tools = {
list_products: {
description: 'List products from your Polar account',
parameters: {
type: 'object',
properties: {
page: { type: 'number', default: 1 },
limit: { type: 'number', default: 20 },
},
},
handler: async (params: any, env: Env, userId: string) => {
const token = await getValidToken(userId, env);
const response = await fetch(
`https://api.polar.sh/v1/products?page=${params.page}&limit=${params.limit}`,
{
headers: { Authorization: `Bearer ${token}` },
}
);
if (!response.ok) {
throw new Error(`Polar API error: ${response.status}`);
}
return response.json();
},
},
};
Each tool is a function that gets parameters from the MCP client, authenticates against the third-party API, and returns data. The MCP protocol handling wraps around these tools — parsing the JSON-RPC requests, routing to the right handler, formatting responses.
Error Handling at the Edge
Workers have a 30-second execution limit on the free plan (extended on paid plans, but still finite). Third-party APIs can be slow. You need to handle timeouts explicitly:
async function fetchWithTimeout(
url: string,
options: RequestInit,
timeoutMs: number = 10000
): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}
For MCP specifically, you also need to handle the case where the third-party API is down entirely. Return a clear error through the MCP protocol rather than letting the Worker crash:
try {
const result = await tool.handler(params, env, userId);
return mcpSuccess(result);
} catch (error) {
return mcpError(error.message, 'TOOL_EXECUTION_ERROR');
}
When Workers Make Sense vs a Proper Backend
Workers are great when:
- Your MCP server is proxying API calls to a third-party service
- State is limited to tokens and simple key-value data
- You want global low-latency access
- You don't need complex data processing or database queries
Use a proper backend (Laravel, for instance) when:
- Your MCP server needs database access with complex queries
- You're doing AI processing as part of the tool execution
- You need background jobs or long-running operations
- The logic is complex enough that TypeScript at the edge becomes painful
For the Polar MCP server, Workers were perfect. It's a thin proxy layer — authenticate, forward the request, return the response. There's no business logic, no database, no processing. Just plumbing.
For something like DevTrends' MCP tools — which query a PostgreSQL database, run aggregations, and sometimes trigger AI processing — Workers would be the wrong choice. That lives in Laravel where it belongs.
Pick the right tool. Workers are brilliant at what they're designed for. Don't force them to be something they're not.
Deployment
npx wrangler deploy
That's genuinely it. Wrangler handles bundling, uploading, and distributing to Cloudflare's edge. KV namespaces need to be created first (wrangler kv:namespace create TOKENS), and secrets need to be set (wrangler secret put POLAR_CLIENT_SECRET), but the deployment itself is one command.
Coming from Laravel deployments with server provisioning, queue workers, and SSL certificates, the simplicity is jarring. In a good way.
I write about Laravel, AI tooling, and the realities of building software. More at stuartmason.co.uk.
Get the Friday email
What I shipped this week, what I learned, one useful thing.
No spam. Unsubscribe anytime. Privacy policy.