A security-first walkthrough for shipping MCP at an enterprise B2B SaaS
There’s been an active debate this year about whether the right surface for AI agents is a CLI or an MCP server. From an enterprise lens, that debate is mostly noise. CLI assumes one developer at a terminal, holding their own credentials, accountable to their own laptop. Enterprise assumes thousands of end users, each with their own scopes, their own consent state, and an audit trail that has to survive a SOC 2 review.
Different problems. Different protocols.
I recently shipped the remote MCP server for COR, a B2B SaaS for project and task management. The exciting part (exposing the API as MCP tools that Cursor, Claude Desktop and ChatGPT Apps can call) was maybe one-third of the work. The other two-thirds was identity, consent, and the unglamorous plumbing that makes a B2B integration trustworthy.
The whole thing runs on Cloudflare Workers, with Durable Objects for per-session state, Workers KV for OAuth metadata, and a tiny set of libraries (@modelcontextprotocol/sdk, @cloudflare/workers-oauth-provider, agents, hono) that absorb most of the protocol heavy lifting. I’ll be concrete about that stack toward the end.
This post is the walkthrough I wish I had when I started.
The mental model: your MCP server has two faces
Before talking about specific decisions, internalize this: a remote MCP server for an enterprise SaaS plays two roles at once.
Face one: Authorization Server. When Cursor or Claude Desktop connects, your server is the OAuth 2.0 Authorization Server. It registers them, prompts the user for consent, issues access tokens, and revokes them.
Face two: OAuth client. When a tool actually runs, your server takes the user’s identity and translates it into an authenticated call to your real API. To your own backend, your MCP server is just another OAuth client.
Holding both faces in your head at the same time is what unlocks the rest of the design. Most of the bad MCP servers I see online are bad because they collapsed those two roles: they treat the MCP token as if it were the upstream API token, or worse, they shortcut the whole thing with a long-lived API key.
That’s the first thing to never do.
Pillar 1: OAuth 2.0 delegation, not API keys
Half the MCP servers in the wild ask the user to paste a personal access token into a config file.
For consumer-grade tooling, fine. For enterprise B2B SaaS, that’s a non-starter. It bypasses your IdP, has no consent ceremony, leaves no audit trail tied to a session, and lives forever in plaintext on every machine the user has ever used. It is the opposite of the security posture your customers expect.
The right answer is OAuth 2.0 with delegated upstream auth.
The flow is straightforward once you draw it: the MCP client redirects the user to your MCP server’s /authorize. Your server, in turn, redirects upstream to your real product’s authorization endpoint. The user logs in there (with SSO, MFA, whatever your real product already enforces). Your server gets back a code, exchanges it for an upstream token, and finishes the MCP-side flow by issuing its own access token to the MCP client.
Two tokens. Two scopes. One human identity threaded through both. No long-lived secrets in config files.
Pillar 2: Dynamic Client Registration is not optional
Every MCP client out there (Cursor, Claude Desktop, MCP Inspector, ChatGPT Apps) expects to register itself with your server on first connect, using RFC 7591 (Dynamic Client Registration). They POST /register with a JSON body that includes their redirect_uris, a client_name, sometimes a logo_uri, and the auth methods they support.
If you don’t implement DCR, you have to manually onboard every client. That’s not a real path; new MCP hosts ship monthly.
Two things that are easy to miss:
The metadata is a UX surface, not just protocol plumbing. The client_name and logo_uri the client sends during registration are exactly what the user will see in your consent dialog (“Cursor wants to access your COR account”). If you don’t render that metadata, every consent screen looks like a phishing page.
Public clients exist on purpose. MCP desktop clients have no server side, can’t keep a secret, and use PKCE instead of client_secret. Your DCR endpoint must accept token_endpoint_auth_method: none clients. Hardening to S256-only PKCE is a separate, valid choice, but disabling public clients altogether means no desktop MCP support at all.
Companion-repo tip: if you’re on Cloudflare Workers, the @cloudflare/workers-oauth-provider library implements DCR, the well-known metadata endpoints (RFC 8414 and RFC 9728), and token exchange with one configuration object. You should still understand the RFCs, but you shouldn’t have to write them.
Pillar 3: The consent dialog is a security surface
The HTML page where a user clicks “Allow Cursor to access COR” looks innocent. It is not. It’s where most of the OAuth attack surface lives.
The minimum bar I shipped:
- CSRF protection on the form submission, using a double-submit cookie pattern (a
__Host-prefixed cookie containing a token also embedded as a hidden form field). - State binding. When the user clicks “Approve”, the OAuth
stateparameter that gets sent upstream is cryptographically tied to that specific consent ceremony. The pattern: hash the state token, store it in a__Host-CONSENTED_STATEcookie. On the upstream callback, recompute the hash and refuse to proceed if it doesn’t match. This prevents state fixation attacks where an attacker primes the user’s browser with their ownstateand steals the resulting authorization code. - HMAC-signed “remember this client” cookie. If you don’t want to re-prompt every time the same user reconnects from the same client, you need a tamper-evident cookie. Sign it with a server-side secret (32 bytes from
openssl rand -hex 32), store the result in__Host-APPROVED_CLIENTS, verify the signature server-side on every request.
These are the same primitives a bank login uses, because the threat model is the same: the attacker controls a browser tab and is trying to convince your server it’s the user.
Pillar 4: Token lifetime separation
Two tokens, two faces, two lifetimes.
The token your MCP server issues to the MCP client can be long-lived. I issue 90-day tokens. The reason this is safe is that the token doesn’t grant API access directly: it grants access to your MCP server, which holds the actual upstream credentials.
The upstream token (the one your server uses to call your real API) follows whatever lifetime your product already enforces (typically much shorter, with rotation). Your MCP server transparently refreshes this on every 401, persists the new refresh token to durable storage tied to the session, and retries the call. The MCP client never sees this happen.
Two consequences worth pointing out:
- The MCP client doesn’t need to renavigate the OAuth flow when your product’s tokens rotate. Better UX.
- Your MCP server becomes a credential store. It must be one. That means: refresh tokens at rest are encrypted, the storage is per-session (not a global pool), a single-flight lock prevents two parallel
401s from triggering two refreshes, and you have a clear story for revocation.
If you can’t articulate where the upstream refresh token lives, who can read it, and how it gets purged when the user revokes access, you’re not done.
Pillar 5: Tool annotations as an audit primitive
This is the one nobody talks about, and it bites later.
The MCP spec lets every tool declare annotations: readOnlyHint, destructiveHint, openWorldHint. They sound like cosmetic lint metadata. They’re not. They’re the contract that lets:
- A host UI ask “auto-approve this tool?” or “show a confirmation?” based on intent, not name.
- An audit log say “at 14:02 the user approved a destructive call to
cor_update_taskagainst task 12345” instead of “a tool ran”. - A future policy engine refuse
destructiveHint: truecalls outside a maintenance window, without you having to enumerate tool names.
Set them. On every tool. Day one. Retrofitting annotations after a security review is far more painful than getting them right at write-time. The OpenAI ChatGPT App Store submission process explicitly checks for them, which is a hint that the rest of the ecosystem is moving the same way.
Operational pitfalls worth flagging
A few things that didn’t show up in any blog post and cost me real time:
The WAF / localhost problem. AWS’s AWSManagedRulesCommonRuleSet (and similar default rulesets on other CDNs) blocks the literal string localhost inside request bodies. Every MCP desktop client registers a loopback redirect like http://localhost:6274/callback during DCR. The result: a 403 on POST /register for every desktop MCP client that exists. There is no safe word to whitelist; the rule is doing what it’s supposed to do. The pragmatic fix is to disable that managed rule on the MCP route specifically. Document why, so the next person doesn’t turn it back on. If your platform team has a different posture, you can route MCP traffic to a different distribution where the rule doesn’t apply.
Host rewriting for OAuth metadata. If you front your Worker with a CDN (CloudFront in our case, because the DNS lives in Route 53 and we couldn’t move it), the worker sees a *.workers.dev host by default. Your /.well-known/oauth-authorization-server document will then advertise endpoints under that internal hostname, which won’t match the issuer the client expects. Rewrite the host header at the CDN edge (X-Forwarded-Host: mcp.yourdomain.com) and read it inside the worker before composing metadata.
CSP for embedded widgets. If you ship MCP Apps (React widgets that render inside the chat), the host iframe has its own Content Security Policy. Single-file HTML with all JS+CSS inlined is the cleanest path; remote scripts will be blocked. Bundle React with Vite + vite-plugin-singlefile and ship the resulting HTML inline as a ui://… resource.
Implementation: the Cloudflare stack
Five pillars are theory. Here’s the concrete stack that encodes them.
Why Cloudflare Workers
Three reasons specifically for this workload:
- Streamable HTTP at the edge. MCP’s
2025-11-25transport spec is plain HTTP with chunked responses. Workers handle this natively: no sticky sessions, no WebSocket fragility, no separate load balancer. - Durable Objects give you per-session storage with strong consistency. That’s exactly the shape of an MCP session: one long-lived conversation between a host and your server, where the upstream refresh token must be readable by the same instance that wrote it.
- One package set, zero infrastructure. No Redis to operate, no separate auth service to deploy. Workers KV stores DCR clients and OAuth state. DO SQLite stores per-session tokens. That’s all the persistence the server needs.
The libraries that do the heavy lifting
| Package | Role |
|---|---|
@modelcontextprotocol/sdk | The official MCP TypeScript SDK. Tool definitions, transport, resource registration. |
@cloudflare/workers-oauth-provider | Authorization Server in a box. Implements RFC 7591 (DCR), 8414 (AS metadata), 9728 (protected resource), 6749/8693 (token exchange). |
agents (the McpAgent class) | Wraps the MCP SDK to run inside a Durable Object. Hands you this.server to register tools, takes care of the session lifecycle. |
hono | The default handler (everything that isn’t /mcp, /token, /register). Routes the consent dialog, callback, landing page. |
zod | Schema validation for tool inputs. |
The wiring, in one screen
The entire bootstrap of your MCP server is a single configuration object:
// src/index.ts
import OAuthProvider from "@cloudflare/workers-oauth-provider";
import { McpAgent } from "agents/mcp";
import { handler } from "./handler"; // Hono app (consent, callback, etc.)
export class MCPSession extends McpAgent<Env, State, Props> {
async init() {
// Register tools here. `this.props` carries the authenticated user
// (decrypted by the OAuthProvider from the MCP access token).
registerTools(this.server, this.props);
}
}
export default new OAuthProvider({
apiHandler: MCPSession.serve("/mcp"),
apiRoute: "/mcp",
authorizeEndpoint: "/authorize",
clientRegistrationEndpoint: "/register",
tokenEndpoint: "/token",
defaultHandler: handler,
accessTokenTTL: 90 * 24 * 60 * 60, // 90 days
});
That OAuthProvider instance is your default export. Cloudflare routes requests:
POST /register,POST /token,/.well-known/oauth-authorization-server,/.well-known/oauth-protected-resource→ handled by the library, no code from you.POST /mcp,GET /mcp(Bearer-authenticated) → forwarded to yourMcpAgentDurable Object.- Everything else → your
defaultHandler(the Hono app for/authorize,/callback, landing).
That’s the whole entrypoint. Three files of glue, and you have an RFC-compliant Authorization Server.
Bindings in wrangler.jsonc
The Worker needs two stateful bindings:
{
"name": "my-mcp-server",
"main": "src/index.ts",
"compatibility_date": "2025-03-10",
"compatibility_flags": ["nodejs_compat"],
"kv_namespaces": [{ "binding": "OAUTH_KV", "id": "..." }],
"durable_objects": {
"bindings": [{ "name": "MCP_SESSION", "class_name": "MCPSession" }],
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["MCPSession"] }],
}
OAUTH_KV is the storage the OAuth library uses for DCR clients and state tokens. MCP_SESSION is the Durable Object that holds per-session upstream tokens. The new_sqlite_classes migration is what gives each DO instance its own SQLite database. That’s where the upstream tokens and the refresh single-flight lock live.
Secrets
All sensitive config goes through wrangler secret put:
npx wrangler secret put UPSTREAM_CLIENT_ID
npx wrangler secret put UPSTREAM_CLIENT_SECRET
npx wrangler secret put UPSTREAM_AUTH_URL # your product's authorize endpoint base
npx wrangler secret put UPSTREAM_API_URL # your product's API base
npx wrangler secret put COOKIE_ENCRYPTION_KEY # openssl rand -hex 32
COOKIE_ENCRYPTION_KEY is the secret that signs the __Host-APPROVED_CLIENTS cookie. Rotate it on a schedule; old approvals will fail signature verification and the user will be re-prompted. That’s the intended behavior.
Registering a tool, with annotations
This is the minimum bar I want to see in every tool:
import { z } from "zod";
server.registerTool(
"task_update",
{
description: "Update fields on a task.",
inputSchema: {
task_id: z.string(),
status: z.enum(["new", "in_progress", "done"]).optional(),
title: z.string().optional(),
},
annotations: {
readOnlyHint: false,
destructiveHint: true, // <- the one that actually matters
openWorldHint: true,
},
},
async ({ task_id, status, title }) => {
const result = await upstream.patch(`/tasks/${task_id}`, { status, title });
return {
content: [{ type: "text", text: `Updated task ${task_id}` }],
structuredContent: result,
};
}
);
Three things to notice. The schema is zod, so the MCP host gets a real machine-readable contract. The annotations are explicit: destructiveHint: true is what lets the host show a confirmation, and what lets your audit log mark this call as state-changing. The return shape includes both content[] (for the LLM) and structuredContent (for any UI widget consuming it).
Transparent upstream refresh: the shape
The refresh pattern inside the Durable Object looks roughly like this:
async function authenticatedRequest(path: string): Promise<Response> {
let res = await upstreamFetch(path, this.state.accessToken);
if (res.status !== 401) return res;
// Single-flight: only one refresh in flight per DO instance.
this.refreshPromise ??= refreshUpstreamToken(this.state.refreshToken)
.then(async next => {
this.state.accessToken = next.accessToken;
this.state.refreshToken = next.refreshToken;
await this.ctx.storage.put("tokens", this.state);
})
.finally(() => {
this.refreshPromise = undefined;
});
await this.refreshPromise;
return upstreamFetch(path, this.state.accessToken);
}
The key idea is that the DO instance is the natural lock holder. Every request in the same session lands on the same DO, so the refreshPromise field is enough: no distributed lock, no race. If you tried this with KV, you’d need a separate coordination layer.
CI/CD shape
Push to master → wrangler deploy to your production worker, bound to your custom domain. Push to beta → a wrangler.jsonc environment with its own KV namespace and DO class. No tests required to ship; the verification is tsc --noEmit plus the MCP Inspector pointed at the beta worker:
npx @modelcontextprotocol/inspector
# then connect to https://your-mcp-beta.workers.dev/mcp
The Inspector walks the full OAuth dance, registers itself via DCR, and lets you exercise tools by hand. It’s the single best test harness during development.
A pragmatic checklist
If you take one thing from this post, take this list. It’s the rough order I’d verify before pointing real users at a remote MCP server for an enterprise SaaS:
- OAuth 2.0 with delegated upstream auth (Pillar 1). No PAT pasted into config.
- DCR endpoint live, public clients allowed, PKCE required.
- Consent dialog with CSRF, state binding, HMAC-signed “remember this client” cookie.
- DCR client metadata (
client_name,logo_uri) actually rendered in that dialog. - Upstream refresh transparent on
401, single-flight locked, per-session storage. - Every tool annotated with
readOnlyHint/destructiveHint/openWorldHint. /.well-known/oauth-authorization-serveradvertises the public issuer (not your provider’s internal hostname).- WAF behavior on
localhostin DCR bodies tested and documented. - A clear revocation story end-to-end: revoking on your real product purges the session at the MCP layer.
If any of those items would force a “we’ll get to it” answer, you’re not ready to ship to enterprise customers.
What this gets you
The CLI vs MCP debate is real for indie tooling and personal workflows. For enterprise B2B SaaS, it’s a non-debate: MCP wins, because identity, consent, scope, and audit are first-class in the protocol, and CLI offers none of those by default.
But that protocol-level fitness only matters if you actually use it. An MCP server that ships PATs, no consent dialog, and no tool annotations is just a CLI in a fancier wrapper.
If you build the IAM skeleton right, you get a few things in return:
- A surface your security team can reason about with the same primitives they already use for SSO and OIDC.
- A consent ceremony that makes the user the explicit principal, not the developer who installed the integration.
- An audit trail that survives the SOC 2 question “who did what, on whose behalf, when, and with what scope?”
- A platform that scales to every new MCP host that ships, without manual onboarding.
Tools are the easy part.
Where I’m going next
I’m planning two follow-up posts: one on the MCP Apps side (single-file React widgets, host messaging, structured content), and one on running this end-to-end behind a CDN you don’t fully control.
If your team is evaluating how to expose your platform via MCP, or wiring AI agents into an enterprise stack more broadly, I’m open to a conversation. Easier to spot the wrong tradeoffs from the outside.
Daniel Guzmán, Head of Engineering @ COR