Nan Gao f39cdd6077
fix(mcp): keep rotating OAuth refresh tokens across restarts and Gateway instances (#7207)
* fix(mcp): keep rotating OAuth refresh tokens across restarts and Gateway instances

MCP servers on the OAuth refresh_token grant lost their rotated refresh
token whenever a new process or manager started (restart, tool-cache
reset for non-task servers, every personal-connection run, a second
Gateway instance) and re-seeded with the consumed configured token,
getting invalid_grant for good.

- New table mcp_oauth_tokens (migration 0047_mcp_oauth_tokens), one row
  per refresh_token connection keyed (scope, owner_user_id, server_key):
  ('deployment', '', name) or ('personal', owner, personal runtime
  name). Access and refresh tokens are DEER_FLOW_CREDENTIALS_KEY
  ciphertext; a config fingerprint (token_url, client_id, grant, scope,
  audience, seed) makes a changed seed win.
- OAuthTokenManager re-reads the row under its process lock, adopts a
  peer's valid token, and otherwise claims the refresh with a lease plus
  a version compare-and-set (database clock), fetches outside any
  transaction, writes under the lease and releases it on failure.
- The Gateway installs the store (sync engine via to_thread) around the
  LangGraph runtime for sqlite/postgres; personal managers pass their
  owner. client_credentials stays in process; an unloadable key falls
  back to process memory; database errors fail the refresh.
- credentials_key_consumers lists refresh_token MCP OAuth servers, so a
  declared multi-instance deployment needs the shared key.

Docs: MCP_SERVER.md, CONFIGURATION.md, Helm README, README, mcp and
migrations AGENTS.md, CHANGELOG (en/zh).

* docs(changelog): link the MCP OAuth token store entry to #7207

* fix(mcp): prune removed connections' OAuth token rows and ask owners to re-authorize on invalid_grant

Review follow-up (willem-bd):
- Both MCP config writers drop mcp_oauth_tokens rows of connections a
  save removes (deployment: _commit_mcp_config_write; personal:
  _mutate, keeping each connection's enabled/disabled runtime-name
  variants so a toggle keeps its chain). Best effort; no startup sweep,
  since a missing config file would look like every server removed.
- invalid_grant on a refresh_token request raises
  McpOAuthReauthorizationRequired: the tool error names the server and
  asks for a new refresh token, plus the shared-key hint when this
  instance could not decrypt the stored chain.
- Annotate the gateway helpers' config as AppConfig.

* fix(mcp): renew the OAuth refresh lease, keep unstored rotations, and key access tokens by request parameters

Review follow-up (willem-bd, second round):
- HTTPX timeouts bound each I/O wait, not a whole response: the refresh
  holder now renews its lease every third of REFRESH_LEASE_SECONDS while
  the token request runs and abandons it after REFRESH_MAX_SECONDS (120 s);
  waiters outlast a live holder. Regression uses a real loopback endpoint
  that streams its body for three lease lengths.
- A refresh whose tokens could not be stored keeps them pending
  (unsaved_base_version): they are written back before an older stored
  token is adopted or refreshed with, retried every few seconds while the
  access token is still valid, and dropped once a peer stored a newer
  chain. A failed save releases the lease.
- New token_request_fingerprint (extra_token_params and response fields):
  a stored access token is reused only under the same request parameters;
  otherwise the stored refresh-token chain is refreshed.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-21 18:17:29 +08:00

576 lines
29 KiB
Python

"""OAuth token support for MCP HTTP/SSE servers."""
from __future__ import annotations
import asyncio
import logging
import threading
import time
from collections.abc import Collection
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from typing import Any
from deerflow.config.extensions_config import ExtensionsConfig, McpOAuthConfig
from deerflow.mcp import oauth_store
from deerflow.mcp.headers import apply_header_overrides, header_spellings, illegal_header_value_reason
from deerflow.mcp.oauth_store import (
McpOAuthTokenKey,
McpOAuthTokenKeyUnavailable,
McpOAuthTokenStore,
McpOAuthTokenStoreUnavailable,
get_mcp_oauth_token_store,
oauth_config_fingerprint,
oauth_token_request_fingerprint,
)
logger = logging.getLogger(__name__)
# Used when the token endpoint omits ``expires_in`` or returns a value the
# manager cannot turn into a lifetime.
_DEFAULT_TOKEN_LIFETIME_SECONDS = 3600
# How often a still-valid token retries storing a rotation the store missed.
_UNSAVED_ROTATION_RETRY_SECONDS = 5.0
class McpOAuthReauthorizationRequired(RuntimeError):
"""The token endpoint rejected the refresh token (``invalid_grant``); only a new one fixes the connection.
The message names the server and the remedy, never a token: on the tool
interceptor path it is the model-visible tool error the owner reads.
"""
class _RefreshTokenRejected(Exception):
"""Internal: ``invalid_grant`` for a refresh_token request, before the server name is known."""
def __init__(self) -> None:
super().__init__("invalid_grant")
# Set when this process fell back to the seed because it could not
# decrypt the chain a peer stored: a key mismatch, not a lapsed grant.
self.stored_chain_unreadable = False
def _is_invalid_grant(response: Any) -> bool:
if getattr(response, "status_code", None) not in (400, 401):
return False
try:
payload = response.json()
except ValueError:
return False
return isinstance(payload, dict) and payload.get("error") == "invalid_grant"
def _reauthorization_message(server_name: str, *, stored_chain_unreadable: bool) -> str:
message = f"The OAuth provider rejected the refresh token for MCP server '{server_name}' (invalid_grant). Re-authorize this connection by configuring a new refresh token for it."
if stored_chain_unreadable:
message += " This Gateway instance could not decrypt the token chain another instance stored, so every instance needs the same DEER_FLOW_CREDENTIALS_KEY; ask the operator to set it."
return message
@dataclass
class _OAuthToken:
"""Cached OAuth token."""
access_token: str
token_type: str
expires_at: datetime
@dataclass
class _OAuthState:
"""One connection's mutable tokens and cross-loop refresh lock."""
config: McpOAuthConfig
token: _OAuthToken | None = None
# Sync tool wrappers and the Gateway can refresh from different loops.
lock: threading.Lock = field(default_factory=threading.Lock)
# Shared-store row and token-chain identity; only the refresh_token grant
# has a rotating credential worth sharing across processes.
store_key: McpOAuthTokenKey | None = None
fingerprint: str | None = None
request_fingerprint: str | None = None
# Set when this process's latest refresh could not be stored: the row
# version it refreshed from. Peers still see the consumed refresh token,
# so the live one in ``config`` is written back before anything older is
# adopted or refreshed with.
unsaved_base_version: int | None = None
unsaved_retry_at: float = 0.0
class OAuthTokenManager:
"""Acquire/cache/refresh OAuth tokens for MCP servers."""
def __init__(
self,
oauth_by_server: dict[str, McpOAuthConfig],
*,
owner_user_id: str | None = None,
token_store: McpOAuthTokenStore | None = None,
):
# Refresh-token rotation belongs to runtime state, not the parsed config
# used by cache/snapshot validation or custom interceptor builders.
# A plain threading.Lock, not asyncio.Lock: the embedded/TUI sync tool-call
# path (DeerFlowClient.stream() -> LangGraph ToolNode._func -> a
# ThreadPoolExecutor -> deerflow.tools.sync.make_sync_tool_wrapper's
# per-call asyncio.run()) invokes get_authorization_header from a fresh
# event loop on a fresh OS thread for every concurrent tool call. An
# asyncio.Lock binds to whichever loop first contends on it; a second
# caller's release/wake-up crossing loops without call_soon_threadsafe
# either deadlocks silently or raises "bound to a different event loop".
# threading.Lock has no loop affinity, so it is safe to share across
# however many event loops/threads call into the same server's lock.
#
# ``owner_user_id`` marks a personal connection's manager; ``token_store``
# overrides the host-installed store (``oauth_store``) for refresh_token
# connections, which then survive restarts and are shared by replicas.
self._token_store = token_store
self._states = {name: self._new_state(name, oauth, owner_user_id) for name, oauth in oauth_by_server.items()}
@staticmethod
def _new_state(server_name: str, oauth: McpOAuthConfig, owner_user_id: str | None) -> _OAuthState:
state = _OAuthState(config=oauth.model_copy(deep=True))
if oauth.grant_type == "refresh_token":
state.store_key = McpOAuthTokenKey.for_server(server_name, owner_user_id)
state.fingerprint = oauth_config_fingerprint(oauth)
state.request_fingerprint = oauth_token_request_fingerprint(oauth)
return state
@classmethod
def from_extensions_config(
cls,
extensions_config: ExtensionsConfig,
*,
shared_manager: OAuthTokenManager | None = None,
owner_user_id: str | None = None,
token_store: McpOAuthTokenStore | None = None,
) -> OAuthTokenManager:
"""Build a manager, optionally reusing already-validated connection state.
The durable runtime supplies ``shared_manager`` only after validating
the deployment snapshot. Personal connections must never use it; they
pass their ``owner_user_id`` instead, which keys their stored tokens.
"""
oauth_by_server: dict[str, McpOAuthConfig] = {}
for server_name, server_config in extensions_config.get_enabled_mcp_servers().items():
if server_config.oauth and server_config.oauth.enabled:
oauth_by_server[server_name] = server_config.oauth
manager = cls(oauth_by_server, owner_user_id=owner_user_id, token_store=token_store)
if shared_manager is not None:
for name in manager._states.keys() & shared_manager._states.keys():
manager._states[name] = shared_manager._states[name]
return manager
def has_oauth_servers(self) -> bool:
return bool(self._states)
def oauth_server_names(self) -> list[str]:
return list(self._states)
async def get_authorization_header(self, server_name: str) -> str | None:
state = self._states.get(server_name)
if state is None:
return None
oauth = state.config
token = state.token
if token and not self._is_expiring(token, oauth) and state.unsaved_base_version is None:
return self._authorization_value(token, server_name)
lock = state.lock
# Acquire the OS-level lock off-thread so a blocking wait never blocks this
# event loop, then release it synchronously (release() never blocks). This
# keeps the de-duplication behavior of the old `async with lock:` (only one
# concurrent caller per server actually fetches a token) while remaining
# safe when callers are on different event loops/threads.
#
# The acquisition itself runs as an explicit Task, shielded from this
# coroutine's own cancellation. A bare `await asyncio.to_thread(lock.acquire)`
# cannot be safely cancelled: once the executor thread has started running
# lock.acquire(), Python has no way to stop it, so a cancellation delivered
# at that await would still let the thread go on to acquire the lock later
# (whenever the current holder releases it) with this coroutine already
# gone and nobody left to call release() -- the lock would stay locked
# forever and every later call for this server would block permanently at
# this same line. Shielding the acquisition task means a cancelled caller
# can instead wait for that (unstoppable) acquisition to actually land and
# release the lock immediately, rather than leaking ownership of it.
acquire_task = asyncio.create_task(asyncio.to_thread(lock.acquire), name=f"oauth-lock-acquire:{server_name}")
try:
await asyncio.shield(acquire_task)
except asyncio.CancelledError:
# Keep waiting -- shielded on every retry -- until the acquisition
# actually finishes, even if this coroutine is cancelled again while
# cleaning up: the underlying thread cannot be interrupted, so this is
# the only way to learn when the lock becomes ours and release it
# right away instead of leaving it locked forever.
while not acquire_task.done():
try:
await asyncio.shield(acquire_task)
except asyncio.CancelledError:
continue
lock.release()
raise
try:
store = self._token_store or get_mcp_oauth_token_store()
token = state.token
if token and not self._is_expiring(token, oauth):
if state.unsaved_base_version is not None and store is not None:
await self._retry_unsaved_rotation(state, store)
return self._authorization_value(token, server_name)
fresh = None
try:
if store is not None and state.store_key is not None:
try:
fresh = await self._refresh_shared(state, store, server_name)
except McpOAuthTokenKeyUnavailable:
logger.warning("Keeping OAuth tokens for MCP server '%s' in this process only: the credentials key cannot be loaded", server_name)
if fresh is None:
fresh = await self._fetch_token(oauth)
logger.info(f"Refreshed OAuth access token for MCP server: {server_name}")
except _RefreshTokenRejected as exc:
logger.warning("OAuth refresh token for MCP server '%s' was rejected (invalid_grant); the connection needs a new refresh token", server_name)
raise McpOAuthReauthorizationRequired(_reauthorization_message(server_name, stored_chain_unreadable=exc.stored_chain_unreadable)) from exc.__cause__
state.token = fresh
return self._authorization_value(fresh, server_name)
finally:
lock.release()
async def _refresh_shared(self, state: _OAuthState, store: McpOAuthTokenStore, server_name: str) -> _OAuthToken:
"""Adopt a peer's token from the shared row, or refresh once for every process.
Runs under the connection's process lock. The row is re-read first,
since a peer (another replica, or this process before a restart) may
already hold a newer token. A refresh is claimed with a lease plus a
compare-and-set on the row's version and never holds a row lock across
the token request; the lease is renewed while the request runs, because
HTTP timeouts bound each I/O wait rather than the whole request. While
a peer holds the lease, the row is polled until its token lands or the
lease expires. The refreshed tokens, including a rotated refresh token,
are written back under the claim; a rotation the store missed stays
pending and is written back before anything older is used.
"""
key, fingerprint, request_fingerprint, oauth = state.store_key, state.fingerprint, state.request_fingerprint, state.config
assert key is not None and fingerprint is not None and request_fingerprint is not None
# A waiter must outlast a live holder, which may renew its lease for
# up to REFRESH_MAX_SECONDS; a dead holder's lease expires sooner.
deadline = time.monotonic() + oauth_store.REFRESH_MAX_SECONDS + oauth_store.REFRESH_LEASE_SECONDS
while True:
version, stored, unreadable = await asyncio.to_thread(store.load, key, fingerprint=fingerprint, request_fingerprint=request_fingerprint)
if state.unsaved_base_version is not None and version != state.unsaved_base_version:
# A peer stored a newer chain after this process's rotation.
state.unsaved_base_version = None
if state.unsaved_base_version is not None:
# The row still holds the refresh token this process consumed;
# store the live one first, then continue from the row.
if await self._store_unsaved_rotation(state, store, version):
continue
else:
if stored is not None:
# The row continues this chain; without one (or under another
# fingerprint) the configured seed is the refresh token.
oauth.refresh_token = stored.refresh_token
if stored.access_token and stored.expires_at is not None:
token = _OAuthToken(access_token=stored.access_token, token_type=stored.token_type or oauth.default_token_type, expires_at=stored.expires_at)
if not self._is_expiring(token, oauth):
return token
lease_owner = await asyncio.to_thread(store.claim, key, fingerprint=fingerprint, expected_version=version)
if lease_owner is not None:
break
if time.monotonic() >= deadline:
raise McpOAuthTokenStoreUnavailable(f"Timed out waiting for another DeerFlow process to refresh the OAuth token for MCP server '{server_name}'")
await asyncio.sleep(oauth_store.REFRESH_POLL_SECONDS)
# A claim on a missing row inserts it at version 0.
base_version = 0 if version is None else version
used_refresh_token = oauth.refresh_token
renewal = asyncio.create_task(self._renew_lease(store, key, lease_owner, server_name), name=f"oauth-lease-renewal:{server_name}")
try:
async with asyncio.timeout(oauth_store.REFRESH_MAX_SECONDS):
fresh = await self._fetch_token(oauth)
except asyncio.CancelledError:
# A cancelled caller cannot await the release; hand it to a worker
# rather than make every peer wait out the lease.
asyncio.get_running_loop().run_in_executor(None, self._release_quietly, store, key, lease_owner)
raise
except Exception as exc:
await asyncio.to_thread(self._release_quietly, store, key, lease_owner)
if isinstance(exc, _RefreshTokenRejected):
exc.stored_chain_unreadable = unreadable
if isinstance(exc, TimeoutError):
raise RuntimeError(f"The OAuth token request for MCP server '{server_name}' did not finish within {oauth_store.REFRESH_MAX_SECONDS:g} seconds") from None
raise
finally:
renewal.cancel()
logger.info(f"Refreshed OAuth access token for MCP server: {server_name}")
try:
saved = await asyncio.to_thread(
store.save,
key,
lease_owner=lease_owner,
fingerprint=fingerprint,
request_fingerprint=request_fingerprint,
access_token=fresh.access_token,
token_type=fresh.token_type,
expires_at=fresh.expires_at,
refresh_token=oauth.refresh_token or "",
rotated=oauth.refresh_token != used_refresh_token,
)
except McpOAuthTokenStoreUnavailable:
# Logged by the store. Free the claim so the pending rotation (or a
# peer) need not wait out the lease once the database answers again.
saved = False
await asyncio.to_thread(self._release_quietly, store, key, lease_owner)
if saved:
state.unsaved_base_version = None
else:
# The token is valid for this process, but peers still read the
# refresh token it consumed: keep the new one pending.
state.unsaved_base_version = base_version
state.unsaved_retry_at = time.monotonic() + _UNSAVED_ROTATION_RETRY_SECONDS
logger.warning("The new OAuth token for MCP server '%s' could not be stored; this process keeps it and retries storing it before other processes refresh again", server_name)
return fresh
async def _store_unsaved_rotation(self, state: _OAuthState, store: McpOAuthTokenStore, version: int | None) -> bool:
"""Write the pending rotation over the row it was refreshed from; ``False`` while a peer holds the lease."""
key, oauth, token = state.store_key, state.config, state.token
assert key is not None and state.fingerprint is not None and state.request_fingerprint is not None
if token is None:
state.unsaved_base_version = None
return True
lease_owner = await asyncio.to_thread(store.claim, key, fingerprint=state.fingerprint, expected_version=version)
if lease_owner is None:
return False
try:
saved = await asyncio.to_thread(
store.save,
key,
lease_owner=lease_owner,
fingerprint=state.fingerprint,
request_fingerprint=state.request_fingerprint,
access_token=token.access_token,
token_type=token.token_type,
expires_at=token.expires_at,
refresh_token=oauth.refresh_token or "",
rotated=True,
)
except McpOAuthTokenStoreUnavailable:
await asyncio.to_thread(self._release_quietly, store, key, lease_owner)
raise
if saved:
state.unsaved_base_version = None
return saved
async def _retry_unsaved_rotation(self, state: _OAuthState, store: McpOAuthTokenStore) -> None:
"""While the local token is still valid, retry storing a missed rotation now and then.
Until it is stored, a peer whose token expires first would refresh with
the consumed refresh token. Best effort: failures wait for the next try.
"""
if time.monotonic() < state.unsaved_retry_at:
return
state.unsaved_retry_at = time.monotonic() + _UNSAVED_ROTATION_RETRY_SECONDS
key = state.store_key
assert key is not None and state.fingerprint is not None and state.request_fingerprint is not None
try:
version, _stored, _unreadable = await asyncio.to_thread(store.load, key, fingerprint=state.fingerprint, request_fingerprint=state.request_fingerprint)
if version != state.unsaved_base_version:
state.unsaved_base_version = None
return
await self._store_unsaved_rotation(state, store, version)
except McpOAuthTokenStoreUnavailable:
pass # Logged by the store.
async def _renew_lease(self, store: McpOAuthTokenStore, key: McpOAuthTokenKey, lease_owner: str, server_name: str) -> None:
"""Keep the claim's lease alive while its token request runs (cancelled when it ends)."""
while True:
await asyncio.sleep(oauth_store.REFRESH_LEASE_SECONDS / 3)
try:
renewed = await asyncio.to_thread(store.renew, key, lease_owner)
except McpOAuthTokenStoreUnavailable:
continue # Logged by the store; the next tick retries.
if not renewed:
logger.warning("The refresh lease for MCP server '%s' expired while its token request was running; another process may refresh too", server_name)
return
@staticmethod
def _release_quietly(store: McpOAuthTokenStore, key: McpOAuthTokenKey, lease_owner: str) -> None:
try:
store.release(key, lease_owner)
except McpOAuthTokenStoreUnavailable:
pass # Logged by the store; the lease expires on its own.
@staticmethod
def _authorization_value(token: _OAuthToken, server_name: str) -> str:
"""Render the Authorization value, refusing one the transport would echo.
The token endpoint's response is not this process's to control: an
``access_token`` or ``token_type`` carrying a newline reaches h11, which
raises with the full value in the message, and
``ToolErrorHandlingMiddleware`` copies that message into a
model-visible ToolMessage. Failing closed here keeps the token out of
the prompt, the checkpoint, and traces, at the one boundary every caller
goes through -- the tool interceptor, the initial discovery headers, and
the durable task path all read their value from here. A token outside
ASCII fails earlier, inside httpx, with only the offending character in
the message; that one is refused for a deliverable error rather than for
secrecy.
The rendered value is what gets checked, not the two fields separately,
because the rendered value is what the transport sees. An
``access_token`` of ``" abc"`` is legal once it sits after ``Bearer ``
even though the field on its own carries leading whitespace, and
rejecting it would deny a token the server would have accepted.
"""
value = f"{token.token_type} {token.access_token}"
reason = illegal_header_value_reason(value)
if reason is not None:
# Names the server and the reason, never the token: this message
# travels to the model on the interceptor path.
raise ValueError(f"OAuth token for MCP server '{server_name}' cannot be sent as an HTTP header value: the Authorization value {reason}. Check what the token endpoint returned for this server.")
return value
@staticmethod
def _is_expiring(token: _OAuthToken, oauth: McpOAuthConfig) -> bool:
now = datetime.now(UTC)
return token.expires_at <= now + timedelta(seconds=max(oauth.refresh_skew_seconds, 0))
async def _fetch_token(self, oauth: McpOAuthConfig) -> _OAuthToken:
import httpx # pyright: ignore[reportMissingImports]
# extra_token_params is spread first so the reserved fields below
# (grant_type, scope, audience, client_id, ...) cannot be silently
# overridden by an operator-supplied key — otherwise the branch logic
# below (which keys off oauth.grant_type) and the value actually sent
# to the token endpoint would disagree.
data: dict[str, str] = dict(oauth.extra_token_params)
data["grant_type"] = oauth.grant_type
if oauth.scope:
data["scope"] = oauth.scope
if oauth.audience:
data["audience"] = oauth.audience
if oauth.grant_type == "client_credentials":
if not oauth.client_id or not oauth.client_secret:
raise ValueError("OAuth client_credentials requires client_id and client_secret")
data["client_id"] = oauth.client_id
data["client_secret"] = oauth.client_secret
elif oauth.grant_type == "refresh_token":
if not oauth.refresh_token:
raise ValueError("OAuth refresh_token grant requires refresh_token")
data["refresh_token"] = oauth.refresh_token
if oauth.client_id:
data["client_id"] = oauth.client_id
if oauth.client_secret:
data["client_secret"] = oauth.client_secret
else:
raise ValueError(f"Unsupported OAuth grant type: {oauth.grant_type}")
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(oauth.token_url, data=data)
if oauth.grant_type == "refresh_token" and _is_invalid_grant(response):
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise _RefreshTokenRejected() from exc
response.raise_for_status()
payload = response.json()
access_token = payload.get(oauth.token_field)
if not access_token:
raise ValueError(f"OAuth token response missing '{oauth.token_field}'")
# Persist a rotated refresh_token so subsequent refreshes use the latest
# value. This updates our private runtime copy only — neither the parsed
# extensions config nor extensions_config.json; the shared token store,
# when installed, receives it from ``_refresh_shared``. Providers that rotate
# refresh tokens (Auth0, Okta, Google, etc.) return a new refresh_token on
# each refresh; discarding it makes the next refresh fail with invalid_grant.
if oauth.grant_type == "refresh_token":
rotated = payload.get("refresh_token")
if isinstance(rotated, str) and rotated:
oauth.refresh_token = rotated
token_type = str(payload.get(oauth.token_type_field, oauth.default_token_type) or oauth.default_token_type)
expires_in_raw = payload.get(oauth.expires_in_field, _DEFAULT_TOKEN_LIFETIME_SECONDS)
try:
expires_in = int(expires_in_raw)
except (TypeError, ValueError, OverflowError):
# ``OverflowError``: ``json.loads`` accepts ``Infinity``/``NaN``, and
# ``int()`` rejects a non-finite float. Treat it like any other
# unusable value instead of failing the whole token fetch.
expires_in = _DEFAULT_TOKEN_LIFETIME_SECONDS
now = datetime.now(UTC)
try:
expires_at = now + timedelta(seconds=max(expires_in, 1))
except OverflowError:
# Either timedelta construction or datetime addition can overflow.
# Let the arithmetic enforce its exact range, avoiding a rounded
# total_seconds() ceiling, and use the same instant for fallback.
expires_at = now + timedelta(seconds=_DEFAULT_TOKEN_LIFETIME_SECONDS)
return _OAuthToken(access_token=access_token, token_type=token_type, expires_at=expires_at)
def build_oauth_tool_interceptor(
extensions_config: ExtensionsConfig,
*,
token_manager: OAuthTokenManager | None = None,
) -> Any | None:
"""Build a tool interceptor that injects OAuth Authorization headers."""
token_manager = token_manager or OAuthTokenManager.from_extensions_config(extensions_config)
if not token_manager.has_oauth_servers():
return None
# The servers' static header spellings, so the injected token replaces a
# static header spelled 'authorization' at the adapter's case-sensitive
# connection merge instead of riding alongside it (see ``mcp/headers.py``).
spellings_by_server = {server_name: header_spellings(server_config.headers) for server_name, server_config in extensions_config.get_enabled_mcp_servers().items()}
async def oauth_interceptor(request: Any, handler: Any) -> Any:
header = await token_manager.get_authorization_header(request.server_name)
if not header:
return await handler(request)
updated_headers = apply_header_overrides(
request.headers,
{"Authorization": header},
spellings=spellings_by_server.get(request.server_name),
)
return await handler(request.override(headers=updated_headers))
return oauth_interceptor
async def get_initial_oauth_headers(
extensions_config: ExtensionsConfig,
*,
token_manager: OAuthTokenManager | None = None,
server_names: Collection[str] | None = None,
) -> dict[str, str]:
"""Get initial OAuth Authorization headers for MCP server connections."""
token_manager = token_manager or OAuthTokenManager.from_extensions_config(extensions_config)
if not token_manager.has_oauth_servers():
return {}
headers: dict[str, str] = {}
requested = set(server_names) if server_names is not None else None
for server_name in token_manager.oauth_server_names():
if requested is not None and server_name not in requested:
continue
try:
value = await token_manager.get_authorization_header(server_name)
except Exception:
logger.warning(
"Skipping initial OAuth header for MCP server '%s' after token fetch failed",
server_name,
exc_info=True,
)
continue
if value:
headers[server_name] = value
return {name: value for name, value in headers.items() if value}