Two MCP server patterns: OAuth passthrough vs identity-aware search
Building Model Context Protocol servers for two very different jobs — fronting a SaaS with an OAuth broker, and exposing an internal knowledge base to authenticated users.
Model Context Protocol has quietly become one of the more useful things to happen to LLM tooling in the last year. It gives you a standard way to expose “here are the tools you can call” to an AI client — Claude Desktop, Claude Code, IDE plugins, whatever. Servers can be stdio subprocesses or, more usefully, remote HTTP services with auth.
I’ve built two MCP servers with fundamentally different shapes, and it turns out the pattern you pick is the interesting question. This is about those two patterns.
What every MCP server looks like
Skeleton first. Whether the implementation is Python (mcp SDK + Starlette + uvicorn or Mangum) or TypeScript, every server has the same bones:
- Declare tools. Each tool has a name, a natural-language description (the model reads this to decide when to call it), and a JSON-Schema
inputSchema. - Register a dispatcher. One handler receives
(name, arguments)and returns MCP content blocks — usuallyTextContentwrapping a JSON dict. - Mount the SDK’s session manager at
/mcp. Instateless=Truemode it’s Lambda- and Fargate-friendly. - Expose the well-known discovery endpoints.
/.well-known/oauth-protected-resource(RFC 9728) advertises where the auth server lives. If you’re brokering your own OAuth,/.well-known/oauth-authorization-server(RFC 8414) too. - Return 401 with a
WWW-Authenticate: Bearer resource_metadata="…"header on unauth’d requests. This is what triggers the MCP client’s OAuth flow.
Everything after that is variation. And the variations, it turns out, cluster into two very different patterns.
Pattern 1: OAuth passthrough (fronting a SaaS)
Say the “tool” you want the LLM to call lives inside an OAuth-native SaaS — a CRM, an HR system, a support platform, anything with a proper OAuth flow of its own. The right pattern is a pure proxy.
- A tiny OAuth broker in front. It speaks MCP’s OAuth dialect (2.1 + PKCE + Client-ID-Metadata-Document) to the client. It speaks the SaaS’s own OAuth dialect on the back side. It translates scopes — an MCP-level scope like
pto:writebecomes the SaaS’s real scope, sayAbsence_Management. It hands the SaaS’s access token back to the MCP client unchanged. - The MCP server itself never validates the token. It doesn’t wrap it. It doesn’t wrap the token in a JWT of its own. It just forwards it on API calls to the SaaS and lets the SaaS accept or reject.
The nice properties:
- No database. No token store. The token is the storage.
- No JWT-signing keys to rotate. No opinion about what a token means.
- State that survives Lambda cold starts through a Fernet-encrypted state blob. OAuth’s
stateparameter and short-lived auth codes are encrypted with a shared key. Any instance holding the key can decrypt. No sticky sessions.
The whole broker fits in a few hundred lines of Python. Deployable to AWS Lambda with Terraform. The reason to build it this way isn’t cleverness — it’s that the SaaS already knows how to authorize its own resources. Any token wrapping the MCP server does is a lie waiting to be caught by a scope check. Passing through the SaaS’s own token means the SaaS’s own permission model is the source of truth.
Pattern 2: Identity-aware internal search
Now flip the problem. You want an MCP server that exposes an internal knowledge base — company docs, engineering runbooks, whatever — to authenticated users. There’s no upstream SaaS to hand the token to. This time the server itself has to know who the user is and what they’re allowed to see.
- Local JWT validation against a cached JWKS. Strict checks on
iss,aud,exp,nbf. Enforce RFC 8707 resource-indicator binding so a token issued for another service is rejected. This is the small print that keeps someone else’s valid token from being weaponized against your server. - Authorization as a separate step. Authentication says “this is Alice.” Authorization intersects Alice’s group memberships with an allow-list of access groups (stored in a catalog table) to decide what she can search over. Auth without authz is trivia.
- Prompt-injection hardening on retrieved content. Search hits are wrapped in XML tags, control characters stripped, size capped. It won’t stop everything but it makes the boundary between “system context” and “user data” harder for a hostile document to blur.
- Never log the
Authorizationheader. Redact aggressively. It sounds obvious. It gets forgotten in every ad-hocprint(request.headers). - Least-privilege IAM. A single-ARN
ScanandGetSecretValue. Nothing else. If someone compromises the function, they don’t get to read the rest of the account. - Origin allowlist. MCP clients are legitimate. Random web pages hitting your MCP endpoint are not.
- Per-user rate limits. An in-memory token bucket keyed by user ID. It’s not perfect (state per instance) but it’s a real defense against a single user pathologically hammering search.
The nice properties here are the mirror image of pattern 1: there is a database, there are opinions about tokens, there is authorization logic. Because the server is the source of truth about who can see what.
When to reach for which
The heuristic is short:
- Is the underlying capability an OAuth-native external service? Passthrough. Don’t build authorization the SaaS has already built.
- Is the underlying capability a resource you own, whose permission model lives in your own systems? Identity-aware. Validate locally, authorize explicitly.
Trying to force pattern 1 onto pattern 2’s problem gets you a server that doesn’t know who’s calling it. Trying to force pattern 2 onto pattern 1 gets you a JWT wrapper around a token you didn’t need to unpack.
The thing MCP is quietly good at
Both of these servers are plugged into the same MCP client with the same config. The client doesn’t care that one is a stateless OAuth proxy and one is a stateful RAG search — they both advertise tools with descriptions, they both accept JSON-Schema-validated arguments, they both return content blocks.
That’s the interesting part. A single 2025-vintage protocol lets you plug two very different capabilities into the same LLM host without either capability learning anything about the other. The pattern isn’t the protocol — the pattern is decoupling capabilities from the model that calls them. MCP is just the shape that decoupling happens to take right now.