Skip to content

Remote MCP, OAuth, and Enterprise Auth

remote MCP · one-time OAuth dance
step 1 of 6 · auth flow
GET /.well-known/oauth-authorization-server
client asks: where do I send auth traffic?

Steps 1 through 4 are the one-time OAuth 2.1 dance. Once the client has a token, steps 5 and 6 look like any other MCP call, just over HTTP with a bearer header. The MCP server is a SaaS API with a tool-shaped facade and an OAuth dance in front.

The previous MCP post built up the protocol from the inside out: tools, resources, prompts, a JSON-RPC envelope, and a host that spawns a local server as a subprocess and talks to it over stdio. That version of MCP is great for a laptop. It falls apart the moment you want a hosted tool that many users share, or a tool that lives inside a cloud account nobody wants to hand you a binary for.

So this post is about what happens when MCP moves to the cloud. The short version: MCP-over-HTTP, with OAuth in front of it. The server is a SaaS API with a tool-shaped facade and an OAuth dance in front. Once you hold that picture, the rest is details.


Stdio doesn't scale to multi-tenant cloud tools

Local stdio works well for personal tools. You trust the binary because you installed it. The OS does the isolation. "Auth" is just whether the process can read your ~/.aws/credentials. There's no network, no shared state, and no question about who you are.

That model breaks as soon as the tool needs to live somewhere other than your laptop. Think about a GitHub MCP server, or a Linear one, or an internal one that exposes your company's deploy system. You can't ship a stdio binary for any of those. The data is in someone else's cloud account. The tool's implementation has to run there, next to the data, and many users need to share it without stepping on each other.

Four forces push MCP out of stdio and onto the network:

  • Multi-tenancy. One server, many users, each seeing only their own data.
  • Hosted SaaS. The tool is owned by a vendor (GitHub, Atlassian, Linear). You don't run their backend; you call it.
  • Serverless tools. The operator wants scale-to-zero. Spinning up a local subprocess per request isn't the right shape.
  • Enterprise control. Security teams want to revoke access centrally, audit every call, and enforce scopes. You can't do any of that when "auth" is a subprocess reading env vars.

The answer is to make MCP look like what it actually is at that point: a web service with a well-defined JSON-RPC surface, protected by the same auth primitives every other cloud API uses. Once you accept that framing, the design follows pretty directly.

transports · same JSON-RPC, different pipes
local · stdio
The MCP server is a local subprocess. The host pipes JSON-RPC lines to its stdin and reads them from stdout. Process boundary is the security boundary.
// client process
spawn("uvx github-mcp")
// write request to stdin
stdin <- {"jsonrpc":"2.0","method":"tools/list"}\n
// read line-delimited responses
stdout -> {"jsonrpc":"2.0","result":...}\n
  • • single-user, single-tenant
  • • auth = "you ran the binary"
  • • transport = OS pipes
  • • framing = one JSON object per line
remote · streamable HTTP
The MCP server is a web service. Client sends requests as HTTP POSTs. For long-running calls, the server can upgrade the response to a Server-Sent Events stream and emit progress notifications.
// one-shot call
POST /mcp  Authorization: Bearer tok_...
Content-Type: application/json
{"jsonrpc":"2.0","method":"tools/call",...}
// long-running: server streams SSE
HTTP/1.1 200 OK
Content-Type: text/event-stream
event: message
data: {"method":"notifications/progress",...}
event: message
data: {"result": ...}
  • • multi-user, multi-tenant
  • • auth = OAuth bearer token
  • • transport = HTTP + SSE (not WebSockets)
  • • framing = JSON body, or SSE events

Same JSON-RPC envelopes, different pipes. stdio wraps them in OS pipes. Streamable HTTP wraps them in POST bodies, with SSE for streaming responses. The MCP layer above the transport doesn't change.


Prereqs

If you've read MCP: A Cross-System Standard for Tool Integration, you have what you need. If you haven't, the one-sentence recap is: MCP is a JSON-RPC protocol that lets an LLM host talk to a tool server, with a small vocabulary of methods (tools/list, tools/call, resources/read, and a few more). This post takes that protocol as given and puts it on the network.

You also want the mental picture from function calling as structured generation: the model emits a tool_use block, the host's runtime catches it, and something runs the tool. In the local-stdio case, that "something" is a subprocess. In the remote case, it's an HTTP request to a server we've authenticated against.


Two transports, same protocol

The MCP spec defines two transports. stdio is what we covered last post: a server launched as a subprocess, JSON-RPC over its stdin/stdout. Streamable HTTP is the remote version. It's easy to describe:

  • Client-to-server is an ordinary HTTP POST with a JSON-RPC envelope in the body. Single request, single response, for most calls.
  • Server-to-client streaming uses Server-Sent Events. When a tool call will take a while, or when the server wants to send progress notifications, the response is upgraded to Content-Type: text/event-stream and the server emits a sequence of event: message frames, each containing a JSON-RPC object.

No WebSockets, no custom binary framing. A request is a POST, a streaming response is SSE, and what travels through them is the same JSON-RPC envelopes MCP always uses. The choice of SSE over WebSockets is deliberate: SSE is a one-way stream layered on ordinary HTTP, which means it works through every corporate proxy, every CDN, every load balancer that already knows about HTTP. That matters for enterprise deployments.

One important distinction: streamable HTTP is not a replacement protocol. It's a replacement transport. The methods, the schemas, the tool definitions, everything above the transport is identical. If you port a stdio server to HTTP, you rewrite the plumbing and nothing else.


The auth model: bearer tokens, end of story

Once MCP is on HTTP, the authentication question is the same one every other HTTP API faces: how do we know which user is making this call, and what are they allowed to do?

The MCP authorization spec picks the answer that the rest of the web has settled on. Every authenticated request carries an Authorization: Bearer <token> header. The server validates the token and looks up the user and their scopes. If the scopes cover the tool being called, the call runs. If not, it returns 403.

The interesting question is how the client got that token in the first place, and the answer is: OAuth 2.1 with PKCE. OAuth has a reputation for being complicated, and most of the complexity is historical baggage that 2.1 finally cleaned up.


OAuth is about delegated access. You have an account on GitHub. You want Claude Code to act on your GitHub account without handing it your password. OAuth is the protocol that lets you grant it a scoped, revocable, time-limited capability to do that.

OAuth 2.1 is the consolidation of fifteen years of learning. It keeps the authorization-code flow, mandates PKCE even for public clients, kills the implicit flow, and tightens everything else. If you learned OAuth 2.0 and were confused about which flow to use when, OAuth 2.1 is the "pick this one" edition. The one flow is authorization code with PKCE.

Here's what each step is doing, from the client's perspective. I'll walk through it in prose, then show the actual HTTP shape.

Discovery. The client hits https://example.com/.well-known/oauth-authorization-server and gets back a metadata document telling it where the authorize, token, and registration endpoints live. This replaces what used to be hardcoded per provider.

Registration. Optionally, the client POSTs to the registration endpoint and gets a client_id. Dynamic client registration (RFC 7591) means a brand-new MCP client on someone's laptop can register itself without the user pasting OAuth app credentials out of a console.

PKCE setup. The client generates a random code_verifier (a secret it keeps), computes code_challenge = BASE64URL(SHA256(code_verifier)), and remembers both. This is the PKCE piece, and it's what makes this flow safe for public clients that can't keep a client secret. RFC 7636 is short and worth reading.

PKCE · why a stolen auth code is worthless · 1. generate pair
client memory (private)
code_verifier (secret)
dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
code_challenge = SHA256(verifier)
E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
what goes over the wire
// nothing on the wire yet
// client keeps the verifier, commits to the challenge

The verifier never leaves the client until token time, and by then the server already knows what its hash should be. A code sniffed off the loopback redirect is a coupon that can't be redeemed without the secret that was committed to up front.

PKCE in one picture. The client commits to a hash of a secret before anyone sees the auth code, then reveals the secret at token exchange time. An attacker who steals the auth code off the redirect can't redeem it without the verifier.

Authorize. The client opens the user's browser to the authorize endpoint with the challenge, the requested scopes, a redirect URI, and a state parameter. The user signs in on the server's domain, sees the scopes spelled out, and clicks allow.

Redirect with code. The server redirects the browser back to the client's local loopback listener with a short-lived code. This code is a coupon. It's worthless on its own.

Token exchange. The client POSTs the code plus the original code_verifier to the token endpoint. The server checks that SHA256(verifier) matches the challenge it saw at authorize time, and if it does, issues an access_token (and usually a refresh_token).

Authenticated calls. From here on, the client just sticks Authorization: Bearer <token> on every MCP request.

OAuth 2.1 · authorization code + PKCE
CLIENT · 0. generate PKCE pair
code_verifier  = random_urlsafe(64)
code_challenge = BASE64URL(SHA256(code_verifier))
Client invents a one-time secret (verifier) and a hash of it (challenge). Only the hash goes out first. The verifier proves the client is the same one that started the flow.

PKCE is the piece that makes this safe without a client secret. The client commits to a hash up front and reveals the pre-image at token time. An attacker who steals the auth code off the redirect URL can't do anything with it.

The full OAuth 2.1 + PKCE dance, end to end. Discover, register, generate the PKCE pair, redirect through the browser for user consent, swap the code plus verifier for an access token, then send authenticated requests. Five HTTP exchanges, then nothing for the lifetime of the token.

The thing I want you to carry away from that viz: PKCE's whole job is to bind the authorize step to the token step, without requiring a shared secret. A client commits to a hash before anyone sees the auth code, and reveals the pre-image at token time. An attacker who steals the code off the redirect URL can't redeem it because they don't have the verifier. The rest of OAuth 2.1 is bookkeeping around that idea.

For native apps and command-line tools, the client listens on a loopback address (http://127.0.0.1:<random_port>/cb) and the browser redirects there. RFC 8252 is the spec for this pattern, and it's what every serious CLI OAuth client uses. Some constrained clients can't run a local server; those use the device code grant (RFC 8628) instead, where the client displays a user code and the user types it into a separate browser. Same idea, different UX.


The runtime: what actually happens at call time

Walk through a concrete session. You open Claude Code in a repo and type "file an issue on the dependency upgrade branch."

  1. Claude Code needs a remote MCP server for GitHub. It checks its config, sees https://github.mcp.example.com, and fetches the server's /.well-known/oauth-authorization-server metadata.
  2. No access token yet, so Claude Code runs the OAuth dance. Your browser pops open, you see "Claude Code wants repo, read:user access on GitHub," you click allow. A loopback redirect delivers the code, Claude Code exchanges it for a token. This happens once.
  3. Claude Code calls tools/list with Authorization: Bearer <token>. The server filters the response to only the tools the token's scopes cover. create_issue is in there. delete_repo is not, because you didn't grant admin.
  4. The model emits a tool_use for create_issue. The runtime packs that into a JSON-RPC tools/call, POSTs it to /mcp. The server streams back progress events over SSE while it talks to the GitHub API, then sends a final result.
  5. The runtime appends the tool result to the transcript and calls the model again. The model writes a sentence to you. stop_reason = end_turn. Done.

The loop is exactly the agent loop from earlier in the arc. All that's new is the network hop and the Authorization header. Auth is a one-time thing. Everything after it is ordinary MCP.


Scopes and the trust boundary

Stop and notice what just happened to the trust model.

With a stdio MCP server, the security boundary is the process boundary. You trust the binary you installed, the OS enforces the rest, and there's no concept of "scope" because the server runs as you and inherits your permissions.

With a remote MCP server, the user is delegating a slice of cloud-account access to the LLM host via OAuth scopes. The server is the gatekeeper. The token encodes exactly which tools the client is allowed to call. The LLM never sees tools outside that scope; from its perspective, a tool it lacks permission for simply doesn't exist in tools/list.

scopes gate the tool surface · scope: read:user
tools/list response (filtered by token scope)
get_user
scope: read:user
read
search_repos
scope: read:user
read
get_issue
scope: repo
read
list_pull_requests
scope: repo
read
create_issue
scope: repo
write
merge_pr
scope: repo
write
delete_repo
scope: admin
destructive
transfer_org
scope: admin
destructive
Read-only viewer. The server exposes two tools. Destructive operations aren't even visible.

The server filters tools/listby the scopes embedded in the bearer token. From the model's perspective, a scope it lacks is a tool that simply doesn't exist.

This is a real improvement over the "hand the model your API key and hope" era of early agent demos. The token is scoped, revocable, auditable, and central. A security team can revoke a token in one place and cut off every MCP client that was using it, see every call in the audit log, grouped by user and by scope, and enforce rate limits per user. None of that was possible with a local binary reading env vars.

The other thing worth noticing: scopes are declared by the server, granted by the user, and enforced at call time. The LLM doesn't negotiate them. The client doesn't assume them. The server is the source of truth for what a given token can do, and every call is re-checked against that. This is the same discipline as any well-designed API; MCP just piggybacks on it.


Enterprise auth, briefly

Once you're on OAuth, you inherit the whole enterprise auth stack. In practice, this is what most companies actually want:

  • SSO via OIDC. The MCP server's authorize endpoint redirects the user through the company's identity provider (Okta, Entra, Google Workspace). The user's corporate account is the one granting the scopes. If HR deprovisions them, the tokens die.
  • SAML federation. Same idea, older protocol. Still common in big enterprises.
  • Service accounts. For server-to-server, non-interactive callers (a CI job calling an MCP-backed deploy tool), you want a client-credentials-style grant or signed JWTs, not an interactive OAuth flow.
  • API keys. The escape hatch. Fine for a single-user server you run yourself. Falls over the moment a second user shows up, because you can't scope or rotate them cleanly.

I won't go deeper than that because the MCP spec doesn't. It defines OAuth 2.1 + discovery + dynamic registration, and otherwise delegates to the existing identity-provider stack. Which is the right call. Reinventing enterprise SSO would have killed remote MCP in the cradle.


Implementation sketch

Here's the minimal Python client that completes the OAuth dance and makes one tool call. I'm using authlib for the OAuth bits and httpx for everything else. This is close to what a real MCP client does under the hood.

import base64, hashlib, secrets, webbrowser, http.server, threading, httpx
 
SERVER = "https://github.mcp.example.com"
REDIRECT = "http://127.0.0.1:7321/cb"
 
# --- 1. discover ---
meta = httpx.get(f"{SERVER}/.well-known/oauth-authorization-server").json()
 
# --- 2. register (dynamic client registration) ---
reg = httpx.post(meta["registration_endpoint"], json={
    "client_name": "my-mcp-cli",
    "redirect_uris": [REDIRECT],
}).json()
client_id = reg["client_id"]
 
# --- 3. PKCE pair ---
verifier = secrets.token_urlsafe(64)
challenge = base64.urlsafe_b64encode(
    hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
 
# --- 4. kick the user into the browser ---
code_box = {}
class CB(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        code_box["code"] = self.path.split("code=")[1].split("&")[0]
        self.send_response(200); self.end_headers()
        self.wfile.write(b"you can close this tab")
server = http.server.HTTPServer(("127.0.0.1", 7321), CB)
threading.Thread(target=server.handle_request).start()
 
webbrowser.open(
    f"{meta['authorization_endpoint']}?client_id={client_id}"
    f"&redirect_uri={REDIRECT}&response_type=code"
    f"&scope=repo%20read:user"
    f"&code_challenge={challenge}&code_challenge_method=S256"
)
while "code" not in code_box: pass  # wait for redirect
 
# --- 5. swap code + verifier for a token ---
tok = httpx.post(meta["token_endpoint"], data={
    "grant_type": "authorization_code",
    "code": code_box["code"],
    "redirect_uri": REDIRECT,
    "client_id": client_id,
    "code_verifier": verifier,  # the PKCE piece
}).json()
access = tok["access_token"]
 
# --- 6. ordinary MCP from here on ---
h = {"Authorization": f"Bearer {access}", "Content-Type": "application/json"}
tools = httpx.post(f"{SERVER}/mcp", headers=h, json={
    "jsonrpc": "2.0", "id": 1, "method": "tools/list"
}).json()
print([t["name"] for t in tools["result"]["tools"]])
 
result = httpx.post(f"{SERVER}/mcp", headers=h, json={
    "jsonrpc": "2.0", "id": 2, "method": "tools/call",
    "params": {"name": "create_issue",
               "arguments": {"repo": "acme/web", "title": "bump deps"}},
}).json()
print(result["result"])

Forty-odd lines, and most of that is the loopback listener for the redirect. The OAuth complexity lives in one block. Everything after line 47 is the MCP you already know, with one extra header.


Misconceptions

"OAuth is too heavy for tool calls." It does feel heavy the first time you set it up. But it's five HTTP requests for the full dance and then nothing for the lifetime of the token. Compared to the alternatives for delegated access to someone else's cloud account, it's the lightest option that exists. And you write the client once. Every remote MCP server after that reuses the same flow, because discovery and dynamic registration mean there's nothing provider-specific to hand-code. The cost amortizes across every server the client ever talks to.

"API keys are simpler, so just use those." For a personal server you run yourself, totally. A single API key in a config file is fine. The trouble shows up the instant a second user appears. API keys have no standard way to carry user identity, no standard way to express scopes, no standard rotation story, and no way for an IdP to deprovision one when someone leaves the company. You end up reinventing half of OAuth. The right rule of thumb: if there's ever going to be more than one user, start with OAuth. It's less work, not more.

"The model handles auth." It does not, and it shouldn't. The model emits a tool_use block. The host's MCP client is the one that holds the token, attaches the bearer header, handles refresh, and hides the whole thing from the model. From the model's perspective, create_issue either exists in tools/list or doesn't. Auth is infrastructure the host provides, and this separation is load-bearing for safety: a model that could forge its own tokens would be a security disaster.

"Remote MCP means the tool runs in the cloud." Not necessarily. A remote MCP server is just an HTTP endpoint that speaks MCP. What happens behind that endpoint is up to the operator. Plenty of real deployments use a remote MCP server as a broker: it takes authenticated calls, then forwards work to a local agent on the user's own laptop (for things like reading local filesystems) or to a dedicated VM. The transport says nothing about where the work runs. Remote MCP is a deployment shape, not a physical location.

What's next

That covers the full stack for a remote tool call: transport, auth, scopes, and the runtime loop around it. Once you've got tools wired up, the interesting question becomes how the model decides which ones to call and in what order. The next post covers planning and reasoning in agent loops, how modern agents chain tool calls to get through multi-step tasks without wandering off.


Additional reading (and watching)