fix(mcp): allow oauth token to target a custom upstream header

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-08-20 16:53:05 +00:00
parent fad8116cdc
commit 7beb4778ab
16 changed files with 244 additions and 28 deletions

View file

@ -38,11 +38,13 @@ from litellm.constants import MCP_CLIENT_TIMEOUT, MCP_NPM_CACHE_DIR
from litellm.llms.custom_httpx.http_handler import get_ssl_configuration
from litellm.types.llms.custom_http import VerifyTypes
from litellm.types.mcp import (
DEFAULT_OAUTH_TOKEN_HEADER,
MCPAuth,
MCPAuthType,
MCPStdioConfig,
MCPTransport,
MCPTransportType,
resolve_oauth_token_header,
)
@ -227,6 +229,7 @@ class MCPClient:
timeout: float | None = None,
stdio_config: MCPStdioConfig | None = None,
extra_headers: dict[str, str] | None = None,
oauth_token_header: str | None = None,
ssl_verify: VerifyTypes | None = None,
aws_auth: httpx.Auth | None = None,
resolved_auth: httpx.Auth | None = None,
@ -241,6 +244,7 @@ class MCPClient:
self._mcp_auth_value: str | dict[str, str] | None = None
self.stdio_config: MCPStdioConfig | None = stdio_config
self.extra_headers: dict[str, str] | None = extra_headers
self.oauth_token_header: str = resolve_oauth_token_header(oauth_token_header)
self.ssl_verify: VerifyTypes | None = ssl_verify
self._aws_auth: httpx.Auth | None = aws_auth
# A pre-resolved httpx.Auth (e.g. from the v2 credential resolver) attached to the
@ -467,19 +471,24 @@ class MCPClient:
elif self.auth_type == MCPAuth.authorization:
headers["Authorization"] = self._mcp_auth_value
elif self.auth_type == MCPAuth.oauth2:
headers["Authorization"] = f"Bearer {self._mcp_auth_value}"
headers[self.oauth_token_header] = f"Bearer {self._mcp_auth_value}"
elif self.auth_type == MCPAuth.token:
headers["Authorization"] = f"token {self._mcp_auth_value}"
elif self.auth_type == MCPAuth.oauth2_token_exchange:
headers["Authorization"] = f"Bearer {self._mcp_auth_value}"
headers[self.oauth_token_header] = f"Bearer {self._mcp_auth_value}"
elif isinstance(self._mcp_auth_value, dict):
headers.update(self._mcp_auth_value)
# Note: aws_sigv4 auth is not handled here — SigV4 requires per-request
# signing (including the body hash), so it uses httpx.Auth flow instead
# of static headers. See MCPSigV4Auth and _create_httpx_client_factory().
# update the headers with the extra headers
minted_oauth_token: Final = headers.get(self.oauth_token_header)
if self.extra_headers:
headers.update(self.extra_headers)
# A custom oauth_token_header exists so the minted token can ride beside a static
# Authorization, so it owns that header instead of being shadowed by a static entry.
if minted_oauth_token is not None and self.oauth_token_header != DEFAULT_OAUTH_TOKEN_HEADER:
headers[self.oauth_token_header] = minted_oauth_token
return _strip_header_whitespace(headers)
def _create_httpx_client_factory(self) -> Callable[..., httpx.AsyncClient]:

View file

@ -34,6 +34,7 @@ from mcp.types import (
)
from mcp.types import Tool as MCPTool
from pydantic import AnyUrl, BaseModel
from typing_extensions import ReadOnly
import litellm
from litellm._logging import verbose_logger
@ -149,6 +150,7 @@ from litellm.proxy.utils import PrismaClient, ProxyLogging, get_server_root_path
from litellm.repositories.table_repositories import MCPServerRepository
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.mcp import (
DEFAULT_OAUTH_TOKEN_HEADER,
DEFAULT_SUBJECT_TOKEN_TYPE,
MCPAuth,
MCPStdioConfig,
@ -349,6 +351,7 @@ class MCPServerConfig(TypedDict, total=False):
audience: str
subject_token_type: str
upstream_resource: str
oauth_token_header: ReadOnly[str]
id_jag_resource_token_endpoint: str
id_jag_resource: str
client_private_key: str
@ -828,15 +831,17 @@ def _should_strip_caller_authorization(
)
def _without_authorization(
def _without_header(
headers: dict[str, str] | None,
name: str = DEFAULT_OAUTH_TOKEN_HEADER,
) -> dict[str, str] | None:
"""A copy of ``headers`` with any ``Authorization`` key removed (case-insensitive), or
None if nothing remains. Drops only the credential, keeping other forwarded headers.
"""A copy of ``headers`` with ``name`` (``Authorization`` by default) removed
(case-insensitive), or None if nothing remains. Drops only that one header, keeping every
other forwarded header.
"""
if not headers:
return None
filtered: Final = {k: v for k, v in headers.items() if k.lower() != "authorization"}
filtered: Final = {k: v for k, v in headers.items() if k.lower() != name.lower()}
return filtered or None
@ -909,7 +914,7 @@ def _resolve_openapi_tool_auth(
if isinstance(per_server, dict):
authorization: Final = next((v for k, v in per_server.items() if k.lower() == "authorization"), None)
merged: Final = merge_mcp_headers(extra_headers=forwarded, static_headers=_without_authorization(per_server))
merged: Final = merge_mcp_headers(extra_headers=forwarded, static_headers=_without_header(per_server))
if authorization is None:
byok: Final = _format_byok_openapi_auth_header(mcp_server, mcp_auth_header) if mcp_auth_header else None
return byok, merged, mcp_auth_header
@ -976,7 +981,7 @@ def _client_forwarded_authorization_headers(
raw_headers=raw_headers,
user_api_key_auth=user_api_key_auth,
):
return _without_authorization(extra_headers)
return _without_header(extra_headers)
return extra_headers
@ -989,7 +994,7 @@ def _take_forwarded_authorization(
if not headers:
return None, headers
value: Final = next((v for k, v in headers.items() if k.lower() == "authorization"), None)
return value, _without_authorization(headers)
return value, _without_header(headers)
def _passthrough_token_from_mcp_auth_header(
@ -2161,6 +2166,7 @@ class MCPServerManager:
DEFAULT_SUBJECT_TOKEN_TYPE,
),
upstream_resource=server_config.get("upstream_resource", None),
oauth_token_header=server_config.get("oauth_token_header", None),
# ID-JAG fields
id_jag_resource_token_endpoint=server_config.get("id_jag_resource_token_endpoint", None),
id_jag_resource=server_config.get("id_jag_resource", None),
@ -2693,6 +2699,7 @@ class MCPServerManager:
or (credentials_dict.get("subject_token_type") if credentials_dict else None)
or DEFAULT_SUBJECT_TOKEN_TYPE,
upstream_resource=(credentials_dict.get("upstream_resource") if credentials_dict else None),
oauth_token_header=(credentials_dict.get("oauth_token_header") if credentials_dict else None),
# ID-JAG fields — read from credentials JSON blob
id_jag_resource_token_endpoint=(
credentials_dict.get("id_jag_resource_token_endpoint") if credentials_dict else None
@ -3549,8 +3556,10 @@ class MCPServerManager:
# Authorization must NOT shadow it (otherwise the upstream gets e.g. the
# signer's JWT instead of the minted token and rejects it, and for M2M the
# one-shot 401 refetch is lost with it). Drop the conflicting header so the
# resolved token reaches upstream.
return auth, _without_authorization(extra_headers)
# resolved token reaches upstream. Only that header is dropped, since a
# client_credentials server can mint onto a custom oauth_token_header while a
# static Authorization the upstream also requires rides alongside it.
return auth, _without_header(extra_headers, header_name or DEFAULT_OAUTH_TOKEN_HEADER)
# Other modes: an Authorization already supplied via extra_headers (a forwarded caller
# header or static_headers) is intentional and wins; v1 applies those last.
return None, extra_headers
@ -3768,6 +3777,7 @@ class MCPServerManager:
auth_value=auth_value,
timeout=(resolved_server.timeout if resolved_server.timeout is not None else MCP_CLIENT_TIMEOUT),
extra_headers=extra_headers,
oauth_token_header=resolved_server.oauth_token_header,
aws_auth=aws_auth,
sampling_callback=sampling_cb,
elicitation_callback=elicitation_cb,
@ -5312,7 +5322,7 @@ class MCPServerManager:
raw_headers=raw_headers,
user_api_key_auth=user_api_key_auth,
):
extra_headers = _without_authorization(extra_headers)
extra_headers = _without_header(extra_headers)
elif mcp_server.is_client_forwarded_token:
extra_headers = _client_forwarded_authorization_headers(
mcp_server=mcp_server,

View file

@ -35,7 +35,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
Subject,
TokenExchangeConfig,
)
from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE, MCPAuth
from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE, MCPAuth, resolve_oauth_token_header
if TYPE_CHECKING:
from litellm.proxy._types import UserAPIKeyAuth
@ -147,6 +147,7 @@ def _client_credentials_spec(server: MCPServer, resource: str) -> ServerSpec:
audience=server.audience,
upstream_resource=resolve_upstream_resource(server),
token_endpoint_auth_method=server.token_endpoint_auth_method,
token_header=resolve_oauth_token_header(server.oauth_token_header),
),
)

View file

@ -51,6 +51,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
ClientCredentialsConfig,
CredError,
)
from litellm.types.mcp import DEFAULT_OAUTH_TOKEN_HEADER
class TokenEndpointSuccess(BaseModel):
@ -326,10 +327,19 @@ class ClientCredentialsBearerAuth(httpx.Auth):
The initial token was already resolved (so config/IdP failures surfaced as typed errors
before any upstream request); ``refetch`` is the source's 401-recovery callback. If the
refetch fails, or the retried request 401s again, the upstream's response stands.
``header_name`` is the header the token is written to. It defaults to ``Authorization``;
a server whose upstream reads the minted token beside a static ``Authorization`` of its own
configures another header, and only that header is touched here so the static one survives.
"""
def __init__(self, access_token: str, refetch: Callable[[str], Awaitable[str | None]]) -> None:
self.header_name = "Authorization"
def __init__(
self,
access_token: str,
refetch: Callable[[str], Awaitable[str | None]],
header_name: str = DEFAULT_OAUTH_TOKEN_HEADER,
) -> None:
self.header_name = header_name
self._access_token = SecretStr(access_token)
self._refetch = refetch

View file

@ -307,7 +307,7 @@ class UpstreamCredentialProvider:
match await self._client_credentials_source.get(server_id, config):
case Ok(token):
refetch: Final = partial(self._client_credentials_source.refetch, server_id, config)
return Ok(ClientCredentialsBearerAuth(token.access_token, refetch))
return Ok(ClientCredentialsBearerAuth(token.access_token, refetch, header_name=config.token_header))
case Error(err):
return Error(err)

View file

@ -39,7 +39,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.result import (
Ok,
Result,
)
from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE
from litellm.types.mcp import DEFAULT_OAUTH_TOKEN_HEADER, DEFAULT_SUBJECT_TOKEN_TYPE
class AuthSpecKind(str, Enum):
@ -190,6 +190,10 @@ class ClientCredentialsConfig(BaseModel):
the client_credentials grant (sent as `audience` in the token request when set).
`token_endpoint_auth_method` selects how the client authenticates to the token endpoint
(RFC 6749 section 2.3.1); `None` defaults to `client_secret_post`.
`token_header` is the upstream header the minted token is written to. It defaults to
`Authorization`, and an upstream that reads the minted token beside a static `Authorization`
credential of its own configures another header so the two do not collide.
"""
model_config = ConfigDict(frozen=True)
@ -201,6 +205,7 @@ class ClientCredentialsConfig(BaseModel):
audience: str | None = None
upstream_resource: str | None = None
token_endpoint_auth_method: Literal["client_secret_post", "client_secret_basic"] | None = None
token_header: str = DEFAULT_OAUTH_TOKEN_HEADER
class TokenExchangeConfig(BaseModel):

View file

@ -430,7 +430,7 @@ if MCP_AVAILABLE:
_client_forwarded_authorization_headers,
_resolve_openapi_tool_auth,
_should_strip_caller_authorization,
_without_authorization,
_without_header,
global_mcp_server_manager,
)
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
@ -1730,7 +1730,7 @@ if MCP_AVAILABLE:
raw_headers=raw_headers,
user_api_key_auth=user_api_key_auth,
):
extra_headers = _without_authorization(extra_headers)
extra_headers = _without_header(extra_headers)
elif is_client_forwarded_mode:
if not withhold_forwarded_authorization:
extra_headers = _client_forwarded_authorization_headers(

View file

@ -739,6 +739,7 @@ if MCP_AVAILABLE:
("aws_region_name", "aws_region_name"),
("aws_service_name", "aws_service_name"),
("upstream_resource", "upstream_resource"),
("oauth_token_header", "oauth_token_header"),
)
def _has_non_admin_config_credentials(credentials: "MCPCredentials | None") -> bool:

View file

@ -2,7 +2,7 @@ import enum
from typing import TYPE_CHECKING, Any, Final, Literal
from pydantic import BaseModel
from typing_extensions import TypedDict
from typing_extensions import ReadOnly, TypedDict
from litellm.types.llms.base import HiddenParams
@ -222,8 +222,24 @@ class MCPCredentials(TypedDict, total=False):
top-level request field.
"""
oauth_token_header: ReadOnly[str | None]
"""
Header the gateway-minted upstream OAuth token is written to. Defaults to ``Authorization``.
Set it to another header (e.g. ``x-upstream-oauth``) when the upstream reads the minted token
beside a different static ``Authorization`` credential of its own, which would otherwise
collide with it. Not a secret; stored unencrypted.
"""
MCP_ADMIN_CONFIG_CREDENTIAL_KEYS: Final[tuple[str, ...]] = ("upstream_resource",)
DEFAULT_OAUTH_TOKEN_HEADER: Final = "Authorization"
def resolve_oauth_token_header(configured: str | None) -> str:
"""The header a gateway-minted OAuth token is written to, defaulting to ``Authorization``."""
return (configured or "").strip() or DEFAULT_OAUTH_TOKEN_HEADER
MCP_ADMIN_CONFIG_CREDENTIAL_KEYS: Final[tuple[str, ...]] = ("upstream_resource", "oauth_token_header")
"""Non-secret credential keys returned on read so the admin form can show and clear them. Mirrors
``ADMIN_CONFIG_CREDENTIAL_KEYS`` in ``ui/litellm-dashboard/src/components/mcp_tools/types.tsx``."""

View file

@ -86,6 +86,10 @@ class MCPServer(BaseModel):
# today's behavior; "auto" derives the canonical URI from ``url``; any other value is sent
# verbatim. Resolved by ``oauth_utils.resolve_upstream_resource``.
upstream_resource: str | None = None
# Header the gateway-minted upstream OAuth token is written to. None means ``Authorization``,
# today's behavior. A custom header keeps the minted token from colliding with a static
# ``Authorization`` the upstream also requires.
oauth_token_header: str | None = None
# AWS SigV4 fields
aws_access_key_id: str | None = None
aws_secret_access_key: str | None = None

View file

@ -325,6 +325,44 @@ class TestMCPClient:
assert headers["Authorization"] == "token my-token"
assert headers["X-Custom-Header"] == "custom-value"
def test_oauth_token_lands_on_the_configured_header_beside_a_static_authorization(self):
"""A gateway can require its own static Authorization plus the minted OAuth token on another
header; the static entry must not shadow the token and the token must not displace it."""
client = MCPClient(
server_url="http://example.com/mcp",
transport_type="http",
auth_type=MCPAuth.oauth2,
auth_value="minted-m2m",
oauth_token_header="esb-oauth",
extra_headers={"Authorization": "Bearer static-pat", "esb-oauth": "placeholder", "envlbl": "prod"},
)
headers = client._get_auth_headers()
assert headers == {
"Authorization": "Bearer static-pat",
"esb-oauth": "Bearer minted-m2m",
"envlbl": "prod",
}
def test_oauth_token_defaults_to_authorization_and_still_defers_to_static_headers(self):
client = MCPClient(
server_url="http://example.com/mcp",
transport_type="http",
auth_type=MCPAuth.oauth2,
auth_value="minted-m2m",
)
assert client._get_auth_headers() == {"Authorization": "Bearer minted-m2m"}
with_static = MCPClient(
server_url="http://example.com/mcp",
transport_type="http",
auth_type=MCPAuth.oauth2,
auth_value="minted-m2m",
extra_headers={"Authorization": "Bearer static-pat"},
)
assert with_static._get_auth_headers() == {"Authorization": "Bearer static-pat"}
def test_get_auth_headers_strips_static_header_whitespace(self):
"""
Static header names/values must be stripped of surrounding whitespace.

View file

@ -185,6 +185,37 @@ def test_client_credentials_resolves_upstream_resource_onto_the_config():
assert spec.config.upstream_resource == "https://up.example.com/mcp"
def test_client_credentials_carries_the_configured_token_header():
spec = to_server_spec(
_server(
auth_type=MCPAuth.oauth2,
oauth2_flow="client_credentials",
token_url="https://idp.example.com/token",
client_id="cid",
client_secret="csec",
oauth_token_header="esb-oauth",
)
)
assert spec is not None
assert isinstance(spec.config, ClientCredentialsConfig)
assert spec.config.token_header == "esb-oauth"
def test_client_credentials_token_header_defaults_to_authorization():
spec = to_server_spec(
_server(
auth_type=MCPAuth.oauth2,
oauth2_flow="client_credentials",
token_url="https://idp.example.com/token",
client_id="cid",
client_secret="csec",
)
)
assert spec is not None
assert isinstance(spec.config, ClientCredentialsConfig)
assert spec.config.token_header == "Authorization"
def test_client_credentials_with_incomplete_grant_fields_is_owned_for_fail_closed():
# An M2M server missing its grant fields is still owned by v2 (spec, not None) so it fails
# closed at the source (misconfigured, 500) rather than deferring to v1, which would connect

View file

@ -425,3 +425,29 @@ def test_bearer_auth_rejects_sync_clients():
with httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(200)), auth=auth) as client:
with pytest.raises(RuntimeError):
client.get("https://upstream.example.com/mcp")
@pytest.mark.asyncio
async def test_bearer_auth_writes_a_custom_header_and_leaves_authorization_alone():
# A gateway that reads the minted token beside its own static Authorization: the minted token
# must land on the configured header and must not displace the static credential.
seen: "list[tuple[str, str]]" = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append((request.headers.get("Authorization", ""), request.headers.get("esb-oauth", "")))
return httpx.Response(401 if len(seen) == 1 else 200)
async def refetch(failed: str) -> "str | None":
return "fresh-token"
auth = ClientCredentialsBearerAuth("stale-token", refetch, header_name="esb-oauth")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler), auth=auth) as client:
response = await client.get(
"https://upstream.example.com/mcp",
headers={"Authorization": "Bearer static-pat"},
)
assert response.status_code == 200
assert seen == [
("Bearer static-pat", "Bearer stale-token"),
("Bearer static-pat", "Bearer fresh-token"),
]

View file

@ -400,6 +400,19 @@ async def test_client_credentials_emits_the_minted_bearer():
assert source.gets == ["s"]
@pytest.mark.asyncio
async def test_client_credentials_emits_the_minted_token_on_the_configured_header():
source = _FakeM2MSource(Ok(OAuthToken(access_token="m2m-at")))
config = _M2M.model_copy(update={"token_header": "esb-oauth"})
result = await UpstreamCredentialProvider(client_credentials_source=source).resolve_credentials(
_SUBJECT, _spec(config)
)
assert isinstance(result, Ok)
headers, _ = await _emitted_async(result.ok)
assert headers["esb-oauth"] == "Bearer m2m-at"
assert "Authorization" not in headers
@pytest.mark.asyncio
async def test_client_credentials_ignores_the_subject():
# The contract's no-user-context clause: every caller shares the one client identity.

View file

@ -44,7 +44,7 @@ from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
_obo_retry_applies,
_resolve_openapi_tool_auth,
_should_strip_caller_authorization,
_without_authorization,
_without_header,
)
from litellm.proxy._types import (
LiteLLM_MCPServerTable,
@ -968,6 +968,18 @@ class TestMCPServerManager:
assert server.oauth2_flow == "client_credentials"
assert server.has_client_credentials is True
@pytest.mark.asyncio
async def test_load_servers_from_config_reads_the_oauth_token_header(self):
manager = MCPServerManager()
with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)):
await manager.load_servers_from_config(
self._oauth2_config(oauth2_flow="client_credentials", oauth_token_header="esb-oauth")
)
server = next(iter(manager.config_mcp_servers.values()))
assert server.oauth_token_header == "esb-oauth"
@pytest.mark.asyncio
async def test_load_servers_from_config_accepts_explicit_authorization_code(self):
manager = MCPServerManager()
@ -2406,6 +2418,42 @@ class TestMCPServerManager:
assert client._resolved_auth is not None
assert "authorization" not in {k.lower() for k in (client.extra_headers or {})}
@pytest.mark.asyncio
async def test_minted_token_on_a_custom_header_leaves_a_static_authorization_intact(self):
"""A gateway can require its own static Authorization plus the minted token on another
header. Only the header the resolved auth writes is dropped from the static set, so the
static credential the upstream also needs still ships."""
from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
StaticHeaderAuth,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok
class _FakeProvider:
async def resolve_credentials(self, subject, server):
return Ok(StaticHeaderAuth("Bearer MINTED-M2M", header_name="esb-oauth"))
manager = MCPServerManager(cred_provider=_FakeProvider())
server = MCPServer(
server_id="m2m-custom-header",
name="m2m-custom-header-server",
url="https://up.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
oauth2_flow="client_credentials",
client_id="cid",
client_secret="csec",
token_url="https://idp.example.com/token",
oauth_token_header="esb-oauth",
)
client = await manager._create_mcp_client(
server,
extra_headers={"Authorization": "Bearer static-pat", "esb-oauth": "placeholder", "envlbl": "prod"},
)
assert client._resolved_auth is not None
assert client.extra_headers == {"Authorization": "Bearer static-pat", "envlbl": "prod"}
@pytest.mark.asyncio
async def test_preflight_token_exchange_challenges_on_rejected_subject(self):
"""A subject the IdP rejects must raise the RFC 9728 401 challenge from the preflight, so a
@ -2625,14 +2673,18 @@ class TestMCPServerManager:
if captured_extra_headers:
assert "authorization" not in {k.lower() for k in captured_extra_headers}
def test_without_authorization_drops_only_the_credential(self):
def test_without_header_drops_only_the_credential(self):
# None / empty -> None
assert _without_authorization(None) is None
assert _without_authorization({}) is None
assert _without_header(None) is None
assert _without_header({}) is None
# Only Authorization present -> nothing left -> None (case-insensitive)
assert _without_authorization({"authorization": "Bearer x"}) is None
assert _without_header({"authorization": "Bearer x"}) is None
# Authorization dropped, other headers kept
assert _without_authorization({"Authorization": "Bearer x", "X-Trace-Id": "t"}) == {"X-Trace-Id": "t"}
assert _without_header({"Authorization": "Bearer x", "X-Trace-Id": "t"}) == {"X-Trace-Id": "t"}
# A custom header name leaves a static Authorization intact
assert _without_header({"Authorization": "Bearer pat", "esb-oauth": "Bearer stale"}, "ESB-OAuth") == {
"Authorization": "Bearer pat"
}
@pytest.mark.asyncio
async def test_call_regular_mcp_tool_passthrough_forwards_authorization_with_admission_header(

View file

@ -151,7 +151,7 @@ const DECLARED_APP_CREDENTIAL_KEYS = ["client_id", "client_secret"] as const;
// would destroy admin input), but it must stay OUT of the declared-app set: whether an app exists is
// a distinct question that gates the "app may not match upstream" warning, and a server using dynamic
// client registration can set a resource indicator while having no app at all.
export const ADMIN_CONFIG_CREDENTIAL_KEYS = ["upstream_resource"] as const;
export const ADMIN_CONFIG_CREDENTIAL_KEYS = ["upstream_resource", "oauth_token_header"] as const;
// Minted token material the oauth2 authorize path writes beside the app keys; stripped from restored
// snapshots and from any credentials that transit to the temp-session preview so a stale token never