Build a Zero-Touch OAuth Authorization Workflow with Enterprise-Managed MCP
The 2026-07-28 MCP spec removed the session; Enterprise-Managed Authorization lets your IdP authorize agents with no consent popups. This is the LangGraph reference implementation.
Deepak Bagada
CEO, SaaSNext
- The 2026-07-28 MCP spec removed the initialize handshake and protocol session, making per-request OAuth the natural model.
- Dynamic client registration (RFC 7591) eliminates pre-provisioned client ids across environments.
- The enterprise IdP, not the agent, authorizes on the user's behalf — no browser consent flows in agent fleets.
- Bound token refreshes and write every identity event to the audit log so zero-touch auth stays explainable.
By Deepak Bagada, CEO at SaaSNext & Principal AI Architect.
The MCP specification that landed on 2026-07-28 did more than tidy up protocol details — it removed the initialize handshake and the notion of a persistent protocol session, making MCP stateless. That single change unlocked the pattern every enterprise AI platform has been waiting for: enterprise-managed authorization. Combined with the now-stable Enterprise-Managed Authorization extension, the host — your enterprise identity provider such as Okta, Entra, or Keycloak — authorizes agents on behalf of users. No per-user browser consent popups, no stored refresh tokens inside agent code, no OAuth dance staged in every microservice.
This dispatch walks through a zero-touch OAuth workflow built on LangGraph and the stateless MCP transport: dynamic client registration (RFC 7591), token acquisition with OAuth 2.1 / OAuth 2.0 grants scoped to user entitlements, and token-injected calls to enterprise MCP servers — all with explicit retry, refresh, and audit semantics.
Why Stateless MCP Changes Everything
Under the previous spec, every MCP client had to open a session, complete an initialize handshake, and keep that session alive. That forced long-lived connections and made authorization a session-level concern: one handshake, one token, one user. The 2026-07-28 spec removes the protocol session entirely. Every JSON-RPC request is self-contained and authenticated on its own, which is exactly how OAuth 2.1 expects HTTP APIs to behave.
The practical consequence for architects: an agent can now call a hundred different MCP servers and let the identity layer manage credentials per request, rather than per connection. When a token expires, the agent refreshes or re-acquires one — without tearing down any session, because there is nothing to tear down. If you are wiring enterprise MCP servers into a larger agent platform, our MCP directory tracks compatible servers and transports.
Zero-Touch Authorization: The New Model
Classic OAuth for agents looked like this: the agent hosts a redirect URI, the user clicks "Authorize", they land on a consent screen, and the browser round-trips a code back to the agent. That worked for consumer apps. It does not work for an enterprise fleet of autonomous agents — agents have no browser, consent screens slow automation, and per-user client registrations are an administrative nightmare.
Enterprise-managed authorization flips the model:
- The IdP is the host. Okta, Entra, or Keycloak knows the user, their groups, and their entitlements.
- Dynamic client registration (RFC 7591) lets the workflow create a client on the fly, so there are no pre-provisioned client ids scattered across environments.
- The host authorizes on the user's behalf. Instead of a browser consent flow, the IdP issues a token after evaluating policy — client credentials bound to a service identity, or token exchange from a user session that IT established once.
- MCP stays stateless. Each request carries an
Authorizationheader; there is no session to attach it to.
The result is zero-touch, policy-driven, and fully auditable. The agent never sees a consent screen and never stores user passwords.
Architecture Overview
flowchart LR
APP["LangGraph Workflow"]
REG["Dynamic Client Registration (RFC 7591)"]
TOK["Token Endpoint: client_credentials / token_exchange"]
POLICY["Entitlement Policy Engine"]
MC["Stateless MCP Client (Authorization header per call)"]
S1["MCP Server A"]
S2["MCP Server B"]
subgraph IDP["Enterprise IdP - Okta / Entra / Keycloak"]
REG --> TOK
POLICY --> TOK
end
APP --> REG
REG --> TOK
TOK --> MC
MC --> S1
MC --> S2
The workflow keeps authorization state in graph state, so retries, checkpoints, and audits can see exactly which grant produced which token.
Prerequisites
You need an IdP tenant with client-credentials and dynamic registration enabled, plus at least one enterprise MCP server behind the stateless transport:
# Inspect the IdP's well-known metadata for registration + token endpoints
curl -s https://idp.example.com/.well-known/oauth-authorization-server \
| python3 -m json.tool
DCR endpoint locations differ by vendor: Okta exposes /oauth2/v1/clients, Entra exposes /clients, and Keycloak exposes /realms/{realm}/clients. Read the well-known metadata at runtime instead of hardcoding endpoints — this is the entire point of zero-touch.
Project Layout and Configuration
enterprise-mcp/
├── .env
├── schemas.py
├── tools.py
├── graph.py
└── main.py
.env holds IdP endpoints and grant policy:
# .env
IDP_ISSUER=https://idp.example.com
IDP_REGISTRATION_ENDPOINT=https://idp.example.com/oauth2/v1/clients
IDP_TOKEN_ENDPOINT=https://idp.example.com/oauth2/v1/token
IDP_REGISTRATION_TOKEN=rp_reg_... # minted by IdP admin for DCR
AGENT_CLIENT_NAME=enterprise-media-ops
AGENT_REDIRECT_URIS=
SCOPES=documents:read documents:write crm:read ticketing:write
MCP_BASE_URL=https://mcp-gateway.example.com
MCP_GRANT=client_credentials # or token_exchange
TOKEN_LEEWAY_SECONDS=30
CLOCK_SKEW_SECONDS=10
Schemas
schemas.py models the authorization lifecycle:
# schemas.py
from __future__ import annotations
import os
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Optional
from langgraph.graph import MessagesState
from pydantic import BaseModel, Field
class GrantType(str, Enum):
CLIENT_CREDENTIALS = "client_credentials"
TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange"
class ClientRegistration(BaseModel):
client_id: str
client_secret: Optional[str] = None
registration_access_token: Optional[str] = None
class OAuthToken(BaseModel):
access_token: str
token_type: str = "Bearer"
expires_in: int = 0
scope: str = ""
issued_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@property
def expired(self) -> bool:
leeway = int(os.getenv("TOKEN_LEEWAY_SECONDS", "30"))
age = (datetime.now(timezone.utc) - self.issued_at).total_seconds()
return age >= self.expires_in - leeway
class McpCall(BaseModel):
server: str
method: str
params: dict[str, Any] = Field(default_factory=dict)
class AuthState(MessagesState):
registration: Optional[ClientRegistration] = None
token: Optional[OAuthToken] = None
calls: list[McpCall] = Field(default_factory=list)
results: dict[str, Any] = Field(default_factory=dict)
errors: dict[str, str] = Field(default_factory=dict)
token_refreshes: int = 0
audit_log: list[str] = Field(default_factory=list)
The expired property embeds token leeway directly in the model, so every node reasons about token freshness identically.
Tools: Stateless MCP Client
tools.py implements registration, token acquisition, and the stateless JSON-RPC call:
# tools.py
from __future__ import annotations
import json
import os
import httpx
from langchain_core.tools import tool
from schemas import ClientRegistration, OAuthToken
MCP_BASE = os.getenv("MCP_BASE_URL", "https://mcp-gateway.example.com")
@tool
def register_dynamic_client() -> ClientRegistration:
# Register an OAuth client at runtime per RFC 7591.
payload = {
"client_name": os.getenv("AGENT_CLIENT_NAME", "enterprise-media-ops"),
"grant_types": [os.getenv("MCP_GRANT", "client_credentials")],
"token_endpoint_auth_method": "client_secret_basic",
"redirect_uris": [],
"scope": os.getenv("SCOPES", ""),
}
resp = httpx.post(
os.environ["IDP_REGISTRATION_ENDPOINT"],
json=payload,
headers={
"Authorization": f"Bearer {os.environ['IDP_REGISTRATION_TOKEN']}",
"Content-Type": "application/json",
},
timeout=30,
)
resp.raise_for_status()
body = resp.json()
return ClientRegistration(
client_id=body["client_id"],
client_secret=body.get("client_secret"),
registration_access_token=body.get("registration_access_token"),
)
@tool
def acquire_token(reg: ClientRegistration) -> OAuthToken:
# Zero-touch token acquisition on behalf of the enterprise host.
data = {
"grant_type": os.getenv("MCP_GRANT", "client_credentials"),
"scope": os.getenv("SCOPES", ""),
}
auth = (reg.client_id, reg.client_secret) if reg.client_secret else None
resp = httpx.post(
os.environ["IDP_TOKEN_ENDPOINT"], data=data, auth=auth, timeout=30
)
resp.raise_for_status()
body = resp.json()
return OAuthToken(
access_token=body["access_token"],
token_type=body.get("token_type", "Bearer"),
expires_in=int(body.get("expires_in", 3600)),
scope=body.get("scope", ""),
)
@tool
def call_mcp(server: str, method: str, params: dict, token: str) -> dict:
# Stateless JSON-RPC call; the token travels on every request.
payload = json.dumps(
{"jsonrpc": "2.0", "id": "1", "method": method, "params": params}
)
resp = httpx.post(
f"{MCP_BASE}/{server}",
content=payload,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
timeout=90,
)
resp.raise_for_status()
return resp.json()
There is no initialize handshake and no session object anywhere in this file — the 2026-07-28 stateless spec made that entire class of code obsolete.
Graph: Registration → Token → Calls → Refresh
graph.py wires the lifecycle with a conditional refresh edge:
# graph.py
from __future__ import annotations
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, StateGraph
from schemas import AuthState
from tools import acquire_token, call_mcp, register_dynamic_client
MAX_TOKEN_REFRESHES = 3
def _ensure_registration(state: AuthState) -> dict:
if state.registration is not None:
return {"audit_log": state.audit_log + ["reusing registration"]}
reg = register_dynamic_client.invoke({})
return {"registration": reg, "audit_log": state.audit_log + ["dcr registered"]}
def _acquire_token(state: AuthState) -> dict:
token = acquire_token.invoke({"reg": state.registration})
return {"token": token, "audit_log": state.audit_log + ["token issued"]}
def _run_calls(state: AuthState) -> dict:
results: dict = {}
errors: dict = {}
for call in state.calls:
try:
out = call_mcp.invoke(
{
"server": call.server,
"method": call.method,
"params": call.params,
"token": state.token.access_token,
}
)
results[call.method] = out
except Exception as exc:
errors[call.method] = str(exc)
return {"results": results, "errors": errors}
def _route_after_calls(state: AuthState) -> str:
saw_401 = any("401" in err for err in state.errors.values())
if saw_401 and state.token_refreshes < MAX_TOKEN_REFRESHES:
return "refresh"
return "finish"
def _refresh(state: AuthState) -> dict:
return {"token_refreshes": state.token_refreshes + 1}
def build_auth_graph() -> StateGraph:
g = StateGraph(AuthState)
g.add_node("registration", _ensure_registration)
g.add_node("token", _acquire_token)
g.add_node("calls", _run_calls)
g.add_node("refresh", _refresh)
g.set_entry_point("registration")
g.add_edge("registration", "token")
g.add_edge("token", "calls")
g.add_conditional_edges(
"calls", _route_after_calls, {"refresh": "refresh", "finish": END}
)
g.add_edge("refresh", "token")
return g
Entry Point
main.py seeds the call list and runs the graph:
# main.py
import asyncio
from dotenv import load_dotenv
from graph import build_auth_graph
from schemas import AuthState, McpCall
load_dotenv()
async def main() -> None:
graph = build_auth_graph().compile(checkpointer=InMemorySaver())
state = AuthState(
calls=[
McpCall(server="documents", method="list", params={"folder": "inbox"}),
McpCall(server="crm", method="upsert_contact", params={"id": "c-42"}),
]
)
result = await graph.ainvoke(
state, config={"configurable": {"thread_id": "ops-01"}}
)
print("RESULTS:", result.get("results"))
print("AUDIT:", *result.get("audit_log", []), sep="
- ")
if __name__ == "__main__":
asyncio.run(main())
Retry Rules
The workflow has distinct policies for identity, transport, and calls:
| Layer | Trigger | Action | Cap |
|---|---|---|---|
| DCR | 409 client exists | Look up by name, reuse registration | 1 lookup |
| DCR | 429 / 5xx | Exponential backoff 2**n + jitter, max 30 s |
4 attempts |
| Token | invalid_grant |
Re-register the client, then re-request | 1 re-registration |
| Token | Near expiry | Refresh at expires_in - TOKEN_LEEWAY_SECONDS (30 s) |
MAX_TOKEN_REFRESHES = 3 |
| MCP call | 401 | Refresh token, replay the call once | 3 refresh cycles |
| MCP call | 429 / 5xx | Backoff; non-idempotent methods not auto-replayed | 2 attempts |
| Validation | iat in the future |
Tolerate CLOCK_SKEW_SECONDS (10 s) |
n/a |
Every retry writes a new line to audit_log. That discipline is what makes zero-touch authorization explainable to auditors: they can replay the graph thread and see each registration, issuance, refresh, and 401 in order.
Security Notes
- Never log access tokens; the audit log should store token ids (the
jticlaim) instead. - Prefer
client_secret_basicover body credentials, and rotate secrets through theregistration_access_tokenthe DCR response returns. - Under OAuth 2.1 defaults, PKCE is not needed for client-credentials grants but is mandatory for token-exchange flows involving public clients.
- If a specific scope still requires user consent, the IdP evaluates it during issuance — the agent never performs a browser consent flow itself.
Common Pitfalls
Three mistakes dominate first attempts. Hardcoded endpoints — the IdP can change its authorization-server metadata; always discover them from the well-known document. Refreshing forever — without a bounded token_refreshes counter, a revoked client rotates tokens in an infinite loop; the cap in _route_after_calls exists precisely to break that cycle. Treating MCP like the old spec — code that opens a session, negotiates protocol versions, and stores transport state is dead weight against the 2026-07-28 transport; delete it.
Next Steps
Standardize your MCP server catalog first — browse the MCP directory — then run DCR and token acquisition as a standalone LangGraph subgraph before attaching it to your fleet's main orchestrator. Track spec drift on the latest AI news page: the stateless MCP change and the Enterprise-Managed Authorization extension are both young, and 2026 has moved fast.
FAQ
What is enterprise-managed authorization in MCP?
It is an extension that lets the hosting enterprise identity provider (Okta, Entra, Keycloak) authorize MCP tool calls on behalf of users, using dynamic client registration and OAuth grants instead of per-user browser consent flows. The agent never performs a consent dance; the IdP issues scoped tokens based on policy.
Is the initialize handshake really gone in the 2026-07-28 MCP spec?
Yes. The spec made MCP stateless by removing the initialize handshake and the protocol session. Every JSON-RPC request is self-contained and carries its own authentication, which is exactly the model OAuth 2.1 expects from HTTP APIs.
How is this different from the OAuth 2.0 device flow?
The device flow still requires a human to visit a URL and approve. Enterprise-managed authorization removes the human from the loop entirely: the IdP evaluates the agent's client credentials or token-exchange request against policy and issues a token without any interactive step.
Do I still need per-user consent for enterprise MCP servers?
Not for the flows described here. Scopes are granted at issuance time by the identity provider's policy engine. If your org mandates consent for specific high-risk scopes, the IdP enforces that as part of token issuance — it still never hands a consent screen to the agent.
Which IdPs support dynamic client registration and enterprise-managed MCP?
Okta (/oauth2/v1/clients), Microsoft Entra (/clients), and Keycloak (/realms/{realm}/clients) all support RFC 7591 dynamic client registration and can serve as the enterprise-managed authorization host. Discover their exact endpoints from their authorization-server metadata at runtime.
Enjoyed this breakdown? Get our morning dispatch in your inbox.
Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.
Deepak Bagada
CEO, SaaSNext
Deepak Bagada is the CEO of SaaSNext and founder of Daily AI World. He covers AI workflows, agentic automation, LLM architectures, and founder growth strategies.
Related Intelligence Analysis
The Step-by-Step Guide to Automating Meeting Tasks with Whisper
You're spending 45 minutes after every client meeting typing up notes and manually assigning tasks in Jira. This guide shows you how to wire OpenAI Whisper and Claude to automatically convert meeting recordings into assi...
Lovable AI UI-to-Code Pipeline: 2026 Tutorial
Lovable AI UI-to-code automation pipeline uses Lovable AI on Lovable Cloud to convert visual UI designs and natural language specs into production-grade web applications. UI/UX designers and frontend developers bridging...
Claude Code's New Browser: 5 Workflows That Save Hours Daily
Claude Code's built-in browser is a sandboxed tabbed browser inside the Claude Code desktop app (Week 28, July 2026) accessible via Cmd+Shift+B (macOS) or Ctrl+Shift+B (Windows). It lets Claude open websites, read docume...