feat(mcp): separate confidential client for ID-JAG leg 2 (#46211)

* feat(mcp): separate ID-JAG leg-2 client identity, support public resource clients

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(mcp): format ID-JAG credential changes

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(mcp): satisfy lint gates and regenerate API snapshot for ID-JAG resource client

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(mcp): require a confidential ID-JAG resource client, drop none auth method

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(mcp): drop formatting churn from ID-JAG resource client tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: retrigger after flaky test_mcp_tool_search timeout

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: rerun against main after duplicate gemini-4-argon price keys were fixed

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
joshua-berri 2026-10-14 10:04:31 -04:00 • committed by GitHub
parent aa0279e0a2
commit c85e562a52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 335 additions and 17 deletions

View file

@ -534,6 +534,12 @@ def encrypt_credentials(credentials: MCPCredentials, encryption_key: str | None)
value=client_private_key,
new_encryption_key=encryption_key,
)
id_jag_resource_client_secret: Final = credentials.get("id_jag_resource_client_secret")
if id_jag_resource_client_secret is not None:
credentials["id_jag_resource_client_secret"] = encrypt_value_helper( # rebind-ok: encrypt before persist
value=id_jag_resource_client_secret,
new_encryption_key=encryption_key,
)
# AWS SigV4 credential fields
aws_access_key_id: Final = credentials.get("aws_access_key_id")
if aws_access_key_id is not None:
@ -820,6 +826,7 @@ def decrypt_credentials(
"client_id",
"client_secret",
"client_private_key",
"id_jag_resource_client_secret",
"aws_access_key_id",
"aws_secret_access_key",
"aws_session_token",

View file

@ -448,6 +448,8 @@ class MCPServerConfig(TypedDict, total=False):
upstream_token_header: ReadOnly[str]
id_jag_resource_token_endpoint: str
id_jag_resource: str
id_jag_resource_client_id: ReadOnly[str]
id_jag_resource_client_secret: ReadOnly[str]
client_private_key: str
client_private_key_id: str
client_assertion_signing_alg: str
@ -1504,7 +1506,14 @@ def _consumes_caller_authorization(server: MCPServer) -> bool:
_REGISTRY_DUMP_SECRET_FIELDS: Final = frozenset(
{"authentication_token", "client_secret", "client_private_key", "aws_secret_access_key", "aws_session_token"}
{
"authentication_token",
"client_secret",
"client_private_key",
"id_jag_resource_client_secret",
"aws_secret_access_key",
"aws_session_token",
}
)
@ -2754,6 +2763,8 @@ class MCPServerManager:
# 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),
id_jag_resource_client_id=server_config.get("id_jag_resource_client_id", None),
id_jag_resource_client_secret=server_config.get("id_jag_resource_client_secret", None),
client_private_key=server_config.get("client_private_key", None),
client_private_key_id=server_config.get("client_private_key_id", None),
client_assertion_signing_alg=server_config.get("client_assertion_signing_alg", "RS256"),
@ -3343,6 +3354,12 @@ class MCPServerManager:
credentials_dict.get("id_jag_resource_token_endpoint") if credentials_dict else None
),
id_jag_resource=(credentials_dict.get("id_jag_resource") if credentials_dict else None),
id_jag_resource_client_id=(credentials_dict.get("id_jag_resource_client_id") if credentials_dict else None),
id_jag_resource_client_secret=self._decrypt_credential_field(
credentials_dict.get("id_jag_resource_client_secret") if credentials_dict else None,
"id_jag_resource_client_secret",
credentials_are_encrypted,
),
client_private_key=self._decrypt_credential_field(
credentials_dict.get("client_private_key") if credentials_dict else None,
"client_private_key",

View file

@ -38,6 +38,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
CredError,
HeaderCarrier,
IdJagConfig,
IdJagResourceClient,
NoneConfig,
PassthroughConfig,
PrivateKeyJwtAuth,
@ -69,6 +70,7 @@ __all__ = [
"Error",
"HeaderCarrier",
"IdJagConfig",
"IdJagResourceClient",
"NoOpAuth",
"NoneConfig",
"Ok",

View file

@ -33,6 +33,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
ClientSecretAuth,
CredError,
IdJagConfig,
IdJagResourceClient,
NoneConfig,
PassthroughConfig,
PrivateKeyJwtAuth,
@ -234,6 +235,9 @@ def _id_jag_spec(server: MCPServer, resource: str) -> ServerSpec | None:
client_auth: Final = _id_jag_client_auth(server)
if not org_token_endpoint or not resource_token_endpoint or not client_id or client_auth is None:
return None
resource_client: Final = _id_jag_resource_client(server, client_id, client_auth)
if resource_client is None:
return None
return ServerSpec(
server_id=server.server_id,
resource=resource,
@ -243,6 +247,7 @@ def _id_jag_spec(server: MCPServer, resource: str) -> ServerSpec | None:
resource_token_endpoint=resource_token_endpoint,
client_id=client_id,
client_auth=client_auth,
resource_client=resource_client,
subject_token_type=_id_jag_subject_token_type(server),
audience=server.audience,
resource=server.id_jag_resource,
@ -251,6 +256,19 @@ def _id_jag_spec(server: MCPServer, resource: str) -> ServerSpec | None:
)
def _id_jag_resource_client(server: MCPServer, client_id: str, client_auth: ClientAuth) -> IdJagResourceClient | None:
match (server.id_jag_resource_client_id, server.id_jag_resource_client_secret):
case (None, None):
return IdJagResourceClient(client_id=client_id, client_auth=client_auth)
case (resource_client_id, resource_client_secret) if resource_client_id and resource_client_secret:
return IdJagResourceClient(
client_id=resource_client_id,
client_auth=ClientSecretAuth(client_secret=SecretStr(resource_client_secret)),
)
case _:
return None
def _id_jag_client_auth(server: MCPServer) -> ClientAuth | None:
"""Private-key JWT when a key is configured, else client_secret, else None (defer to v1)."""
if server.client_private_key:

View file

@ -293,9 +293,9 @@ class UpstreamCredentialProvider:
}
return await self._token_endpoint.fetch(
config.resource_token_endpoint,
config.client_id,
config.resource_client.client_id,
leg2_params,
config.client_auth,
config.resource_client.client_auth,
)
match await self._exchanged_tokens.get_or_compute(slot, _exchange, fingerprint=fingerprint):
@ -418,10 +418,10 @@ def _id_jag_fingerprint(subject_token: str, server_id: str, config: IdJagConfig)
Stored beside the bearer and compared on every read, so a rotated assertion or an edited server
config reads as a miss and re-mints instead of serving a bearer authorized under the old policy.
Every exchange parameter derives from the config (endpoints, audience, resource, scopes, client
auth), so a server update that changes any of them must change the key; otherwise the old bearer,
authorized under the old policy, keeps being served until its TTL. Everything is hashed, so no
secret is held in the key.
Every exchange parameter derives from the config (endpoints, audience, resource, scopes, and both
client identities and auth methods), so a server update that changes any of them must change the
key; otherwise the old bearer, authorized under the old policy, keeps being served until its TTL.
Everything is hashed, so no secret is held in the key.
"""
material: Final = "\x00".join(
(
@ -431,6 +431,8 @@ def _id_jag_fingerprint(subject_token: str, server_id: str, config: IdJagConfig)
config.resource_token_endpoint,
config.client_id,
_client_auth_fingerprint(config.client_auth),
config.resource_client.client_id,
_client_auth_fingerprint(config.resource_client.client_auth),
config.subject_token_type,
config.audience or "",
config.resource or "",

View file

@ -341,14 +341,23 @@ class ClientSecretAuth(LiteLLMBaseModel):
ClientAuth = Annotated[PrivateKeyJwtAuth | ClientSecretAuth, Field(discriminator="source")]
class IdJagResourceClient(LiteLLMBaseModel):
"""The identity the gateway presents to the resource AS on leg 2."""
model_config = ConfigDict(frozen=True)
client_id: str
client_auth: ClientAuth
class IdJagConfig(HeaderCarrier):
"""draft-ietf-oauth-identity-assertion-authz-grant (Okta "AI agent token exchange").
Two legs: leg 1 is an RFC 8693 token exchange at the IdP org AS (`org_token_endpoint`) that
swaps the caller's identity token for an ID-JAG assertion; leg 2 is an RFC 7523 jwt-bearer at
the upstream resource AS (`resource_token_endpoint`) that swaps the assertion for the access
token. The gateway authenticates to both endpoints as `client_id` via `client_auth`. Required
fields are enforced at construction so a half-configured server cannot reach the arm.
token. The gateway authenticates to the org AS as `client_id` via `client_auth`, and to the
resource AS as `resource_client`. Required fields are enforced at construction so a
half-configured server cannot reach the arm.
"""
model_config = ConfigDict(frozen=True)
@ -357,6 +366,7 @@ class IdJagConfig(HeaderCarrier):
resource_token_endpoint: str
client_id: str
client_auth: ClientAuth
resource_client: IdJagResourceClient
subject_token_type: str = "urn:ietf:params:oauth:token-type:id_token"
audience: str | None = None
resource: str | None = None

View file

@ -86,8 +86,9 @@ def to_server_spec_fail_closed(server: MCPServer) -> ServerSpec | None:
raise_public(
CredError.of_misconfigured(
"oauth2_id_jag requires token_exchange_endpoint, id_jag_resource_token_endpoint, "
"client_id, and a client_secret or client_private_key; refusing to fall back to "
"a static credential."
"client_id, and a client_secret or client_private_key; id_jag_resource_client_* "
"overrides must set both id_jag_resource_client_id and id_jag_resource_client_secret, or neither. "
"Refusing to fall back to a static credential."
)
)
return spec

View file

@ -34006,6 +34006,28 @@
],
"title": "Id Jag Resource"
},
"id_jag_resource_client_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Id Jag Resource Client Id"
},
"id_jag_resource_client_secret": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Id Jag Resource Client Secret"
},
"id_jag_resource_token_endpoint": {
"anyOf": [
{
@ -36767,6 +36789,28 @@
],
"title": "Id Jag Resource"
},
"id_jag_resource_client_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Id Jag Resource Client Id"
},
"id_jag_resource_client_secret": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Id Jag Resource Client Secret"
},
"id_jag_resource_token_endpoint": {
"anyOf": [
{
@ -40150,6 +40194,28 @@
],
"title": "Id Jag Resource"
},
"id_jag_resource_client_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Id Jag Resource Client Id"
},
"id_jag_resource_client_secret": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Id Jag Resource Client Secret"
},
"id_jag_resource_token_endpoint": {
"anyOf": [
{
@ -44190,6 +44256,28 @@
],
"title": "Id Jag Resource"
},
"id_jag_resource_client_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Id Jag Resource Client Id"
},
"id_jag_resource_client_secret": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Id Jag Resource Client Secret"
},
"id_jag_resource_token_endpoint": {
"anyOf": [
{

View file

@ -246,6 +246,12 @@ class MCPCredentials(TypedDict, total=False):
Optional RFC 8707 resource indicator sent on ID-JAG leg 1
"""
id_jag_resource_client_id: ReadOnly[str | None]
"""OAuth client identifier used at the ID-JAG resource authorization server."""
id_jag_resource_client_secret: str | None # writable-ok: encrypt_credentials updates this field
"""OAuth client secret used at the ID-JAG resource authorization server."""
upstream_resource: str | None
"""
Optional RFC 8707 resource indicator sent on the upstream oauth2 legs (authorize, both token

View file

@ -182,6 +182,14 @@ class MCPServer(LiteLLMBaseModel):
# posts the ID-JAG assertion to id_jag_resource_token_endpoint.
id_jag_resource_token_endpoint: str | None = None
id_jag_resource: str | None = None
id_jag_resource_client_id: str | None = Field(
default=None,
description="OAuth client identifier used at the ID-JAG resource authorization server.",
)
id_jag_resource_client_secret: str | None = Field(
default=None,
description="OAuth client secret used at the ID-JAG resource authorization server.",
)
client_private_key: str | None = None
client_private_key_id: str | None = None
client_assertion_signing_alg: str = "RS256"

View file

@ -707,6 +707,66 @@ def test_id_jag_private_key_wins_over_client_secret():
assert isinstance(spec.config.client_auth, PrivateKeyJwtAuth)
@pytest.mark.parametrize(
("server", "expected_resource_client_id", "expected_secret", "expected_private_key"),
[
(_id_jag_server(), "litellm-client-id", "litellm-client-secret", None),
(
_id_jag_server(client_secret=None, client_private_key="LEG1-PRIVATE-KEY"),
"litellm-client-id",
None,
"LEG1-PRIVATE-KEY",
),
(
_id_jag_server(
id_jag_resource_client_id="resource-confidential-client",
id_jag_resource_client_secret="resource-client-secret",
),
"resource-confidential-client",
"resource-client-secret",
None,
),
],
ids=["legacy-client-secret", "legacy-private-key", "confidential-override"],
)
def test_id_jag_resource_client_uses_its_configured_identity_and_auth(
server: MCPServer,
expected_resource_client_id: str,
expected_secret: str | None,
expected_private_key: str | None,
) -> None:
spec: Final = to_server_spec(server)
assert spec is not None and isinstance(spec.config, IdJagConfig)
resource_client: Final = spec.config.resource_client
assert resource_client.client_id == expected_resource_client_id
if expected_secret is not None:
assert isinstance(resource_client.client_auth, ClientSecretAuth)
assert resource_client.client_auth.client_secret.get_secret_value() == expected_secret
if server.id_jag_resource_client_id is not None:
assert isinstance(spec.config.client_auth, ClientSecretAuth)
assert (
resource_client.client_auth.client_secret.get_secret_value()
!= spec.config.client_auth.client_secret.get_secret_value()
)
else:
assert isinstance(resource_client.client_auth, PrivateKeyJwtAuth)
assert resource_client.client_auth.private_key.get_secret_value() == expected_private_key
@pytest.mark.parametrize(
"server",
[
_id_jag_server(id_jag_resource_client_id="resource-client"),
_id_jag_server(id_jag_resource_client_secret="resource-secret"),
_id_jag_server(id_jag_resource_client_id="", id_jag_resource_client_secret="resource-secret"),
],
ids=["id-only", "secret-only", "empty-id-with-secret"],
)
def test_id_jag_incomplete_resource_client_overrides_return_none(server: MCPServer) -> None:
assert to_server_spec(server) is None
def test_id_jag_honors_explicit_subject_token_type():
spec = to_server_spec(_id_jag_server(subject_token_type="urn:ietf:params:oauth:token-type:saml2"))
assert spec is not None and isinstance(spec.config, IdJagConfig)

View file

@ -10,7 +10,9 @@ returning the stub.
import asyncio
import logging
import time
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
from typing import Final
import httpx2
import jwt as pyjwt
@ -23,11 +25,13 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials import (
AuthorizationCodeConfig,
AwsSigV4Config,
Byok,
ClientAuth,
ClientCredentialsConfig,
ClientSecretAuth,
CredError,
Error,
IdJagConfig,
IdJagResourceClient,
NoneConfig,
NoOpAuth,
Ok,
@ -67,6 +71,10 @@ def _id_jag_config() -> IdJagConfig:
resource_token_endpoint="https://mcp-as.example.com/token",
client_id="litellm",
client_auth=ClientSecretAuth(client_secret=SecretStr("s")),
resource_client=IdJagResourceClient(
client_id="litellm",
client_auth=ClientSecretAuth(client_secret=SecretStr("s")),
),
audience="api://mcp",
scopes=("mcp.read",),
)
@ -78,9 +86,17 @@ class _FakeTokenEndpoint:
def __init__(self, results: list[Result[ExchangedToken, CredError]]) -> None:
self._results = list(results)
self.calls: list[tuple[str, str, dict[str, str]]] = []
self.auths: list[ClientAuth] = []
async def fetch(self, endpoint, client_id, grant_params, client_auth):
async def fetch(
self,
endpoint: str,
client_id: str,
grant_params: Mapping[str, str],
client_auth: ClientAuth,
) -> Result[ExchangedToken, CredError]:
self.calls.append((endpoint, client_id, dict(grant_params)))
self.auths.append(client_auth)
return self._results.pop(0)
@ -509,15 +525,20 @@ async def test_id_jag_runs_both_legs_and_returns_the_leg2_bearer():
]
)
provider = UpstreamCredentialProvider(token_endpoint=endpoint)
result = await provider.resolve_credentials(
_with_inbound("user-id-token"), _spec(_id_jag_config())
resource_auth: Final = ClientSecretAuth(client_secret=SecretStr("resource-secret"))
config: Final = _id_jag_config().model_copy(
update={"resource_client": IdJagResourceClient(client_id="resource-client", client_auth=resource_auth)}
)
result: Final = await provider.resolve_credentials(_with_inbound("user-id-token"), _spec(config))
assert isinstance(result, Ok)
assert _emitted(result.ok)["Authorization"] == "Bearer final-access"
leg1_endpoint, _, leg1_params = endpoint.calls[0]
leg1_endpoint, leg1_client_id, leg1_params = endpoint.calls[0]
assert leg1_endpoint == "https://idp.example.com/token"
assert leg1_client_id == "litellm"
assert isinstance(endpoint.auths[0], ClientSecretAuth)
assert endpoint.auths[0].client_secret.get_secret_value() == "s"
assert (
leg1_params["grant_type"] == "urn:ietf:params:oauth:grant-type:token-exchange"
)
@ -526,8 +547,12 @@ async def test_id_jag_runs_both_legs_and_returns_the_leg2_bearer():
)
assert leg1_params["subject_token"] == "user-id-token"
leg2_endpoint, _, leg2_params = endpoint.calls[1]
leg2_endpoint, leg2_client_id, leg2_params = endpoint.calls[1]
assert leg2_endpoint == "https://mcp-as.example.com/token"
assert leg2_client_id == "resource-client"
assert isinstance(endpoint.auths[1], ClientSecretAuth)
assert endpoint.auths[1].client_secret.get_secret_value() == "resource-secret"
assert endpoint.auths[1].client_secret.get_secret_value() != endpoint.auths[0].client_secret.get_secret_value()
assert leg2_params["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer"
# The leg-1 token is forwarded verbatim as the leg-2 assertion.
assert leg2_params["assertion"] == "the-id-jag"
@ -1071,6 +1096,22 @@ async def test_id_jag_reuses_the_cached_bearer_for_an_unchanged_config():
_id_jag_config().model_copy(update={"resource_token_endpoint": "https://mcp-as.example.com/v2/token"}),
_id_jag_config().model_copy(update={"client_id": "litellm-rotated"}),
_id_jag_config().model_copy(update={"client_auth": ClientSecretAuth(client_secret=SecretStr("rotated"))}),
_id_jag_config().model_copy(
update={
"resource_client": IdJagResourceClient(
client_id="resource-client-rotated",
client_auth=ClientSecretAuth(client_secret=SecretStr("s")),
)
}
),
_id_jag_config().model_copy(
update={
"resource_client": IdJagResourceClient(
client_id="litellm",
client_auth=ClientSecretAuth(client_secret=SecretStr("resource-secret-rotated")),
)
}
),
_id_jag_config().model_copy(update={"subject_token_type": "urn:ietf:params:oauth:token-type:saml2"}),
],
ids=[
@ -1081,6 +1122,8 @@ async def test_id_jag_reuses_the_cached_bearer_for_an_unchanged_config():
"resource_token_endpoint",
"client_id",
"client_auth",
"resource_client_id",
"resource_client_secret",
"subject_token_type",
],
)

View file

@ -23,6 +23,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials import (
CredError,
Error,
IdJagConfig,
IdJagResourceClient,
NoneConfig,
Ok,
PrivateKeyJwtAuth,
@ -42,6 +43,10 @@ _ID_JAG_MINIMAL = {
"resource_token_endpoint": "https://mcp-as.example.com/token",
"client_id": "litellm",
"client_auth": {"source": "client_secret", "client_secret": "s"},
"resource_client": {
"client_id": "litellm",
"client_auth": {"source": "client_secret", "client_secret": "s"},
},
}
@ -230,6 +235,10 @@ def test_id_jag_secrets_do_not_leak_in_repr():
resource_token_endpoint="https://mcp-as.example.com/token",
client_id="litellm",
client_auth=PrivateKeyJwtAuth(private_key=SecretStr("super-secret-pem")),
resource_client=IdJagResourceClient(
client_id="resource-client",
client_auth=PrivateKeyJwtAuth(private_key=SecretStr("super-secret-pem")),
),
)
assert "super-secret-pem" not in repr(config)
@ -255,6 +264,10 @@ _CARRIER_CONFIGS = (
resource_token_endpoint="https://mcp-as.example.com/token",
client_id="litellm",
client_auth=ClientSecretAuth(client_secret=SecretStr("s")),
resource_client=IdJagResourceClient(
client_id="litellm",
client_auth=ClientSecretAuth(client_secret=SecretStr("s")),
),
**kw,
),
),

View file

@ -458,16 +458,24 @@ def test_client_private_key_encrypted_at_rest():
private_key = (
"-----BEGIN PRIVATE KEY-----\nsensitive-rsa-material\n-----END PRIVATE KEY-----"
)
credentials = {"client_secret": "shh", "client_private_key": private_key}
resource_client_secret: Final = "resource-client-secret"
credentials = {
"client_secret": "shh",
"client_private_key": private_key,
"id_jag_resource_client_secret": resource_client_secret,
}
encrypted = encrypt_credentials(dict(credentials), encryption_key=None)
assert encrypted["client_private_key"] != private_key
assert private_key not in encrypted["client_private_key"]
assert encrypted["client_secret"] != "shh"
assert encrypted["id_jag_resource_client_secret"] != resource_client_secret
assert resource_client_secret not in encrypted["id_jag_resource_client_secret"]
decrypted = decrypt_credentials(dict(encrypted))
assert decrypted["client_private_key"] == private_key
assert decrypted["client_secret"] == "shh"
assert decrypted["id_jag_resource_client_secret"] == resource_client_secret
@pytest.fixture(params=["xsalsa20-poly1305", "aes-256-gcm"])

View file

@ -741,6 +741,8 @@ class TestMCPServerManager:
"client_id": "gateway-client",
"client_secret": "SECRET-CLIENT-SECRET",
"client_private_key": "-----BEGIN PRIVATE KEY-----SECRET-PEM-----END PRIVATE KEY-----",
"id_jag_resource_client_id": "resource-client-id",
"id_jag_resource_client_secret": "SECRET-RESOURCE-CLIENT-SECRET",
"token_exchange_endpoint": "https://org-idp.example/oauth2/token",
"id_jag_resource_token_endpoint": "https://resource-as.example/oauth2/token",
"authentication_token": "SECRET-STATIC-TOKEN",
@ -750,11 +752,16 @@ class TestMCPServerManager:
with caplog.at_level(logging.DEBUG, logger="LiteLLM"):
await manager.load_servers_from_config(config)
server: Final = next(iter(manager.config_mcp_servers.values()))
assert server.id_jag_resource_client_id == "resource-client-id"
assert server.id_jag_resource_client_secret == "SECRET-RESOURCE-CLIENT-SECRET"
dump = next(m for m in caplog.messages if "Loaded MCP Servers" in m)
assert "SECRET-PEM" not in dump
assert "SECRET-CLIENT-SECRET" not in dump
assert "SECRET-RESOURCE-CLIENT-SECRET" not in dump
assert "SECRET-STATIC-TOKEN" not in dump
assert "gateway-client" in dump
assert "resource-client-id" in dump
assert "https://org-idp.example/oauth2/token" in dump
async def test_load_servers_from_config_warns_on_invalid_alias(self, config_only_mcp_manager_factory, caplog):
@ -9798,6 +9805,30 @@ class TestMCPServerTokenExchangeColumns:
assert mcp_server.subject_token_type == "urn:ietf:params:oauth:token-type:access_token"
@pytest.mark.asyncio
async def test_build_mcp_server_from_table_reads_id_jag_resource_client_credentials(self):
resource_secret: Final = "resource-secret"
manager: Final = MCPServerManager()
table_record: Final = LiteLLM_MCPServerTable(
server_id="id-jag-resource-client",
server_name="id_jag_resource_client",
url="https://upstream.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2_id_jag,
credentials={
"id_jag_resource_client_id": "resource-client",
"id_jag_resource_client_secret": resource_secret,
},
)
mcp_server: Final = await manager.build_mcp_server_from_table(
table_record,
credentials_are_encrypted=False,
)
assert mcp_server.id_jag_resource_client_id == "resource-client"
assert mcp_server.id_jag_resource_client_secret == resource_secret
@pytest.mark.asyncio
async def test_round_trip_token_exchange_columns_preserved(self):
"""The three fields survive LiteLLM_MCPServerTable -> MCPServer -> LiteLLM_MCPServerTable.

View file

@ -37068,6 +37068,10 @@ export interface components {
dcr_server_url?: string | null;
/** Id Jag Resource */
id_jag_resource?: string | null;
/** Id Jag Resource Client Id */
id_jag_resource_client_id?: string | null;
/** Id Jag Resource Client Secret */
id_jag_resource_client_secret?: string | null;
/** Id Jag Resource Token Endpoint */
id_jag_resource_token_endpoint?: string | null;
/** Redirect Uris */