mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-13 03:59:12 +00:00
Merge f524d452cb into 780c504e00
This commit is contained in:
commit
4841753b4e
36 changed files with 2021 additions and 123 deletions
4
.github/workflows/test-unit.yml
vendored
4
.github/workflows/test-unit.yml
vendored
|
|
@ -794,6 +794,10 @@ jobs:
|
|||
tests/unit/proxy/_experimental/mcp_server/test_discoverable_endpoints.py
|
||||
tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py
|
||||
tests/unit/proxy/_experimental/mcp_server/test_db_credentials.py
|
||||
tests/unit/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py
|
||||
tests/unit/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py
|
||||
tests/unit/proxy/_experimental/mcp_server/test_mcp_max_concurrent_requests.py
|
||||
tests/unit/proxy/_experimental/mcp_server/faults/test_list_outcomes.py
|
||||
tests/unit/proxy/_experimental/mcp_server/outbound_credentials
|
||||
python-version: "3.12"
|
||||
workers: 4
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ class _SessionCallbackMap(TypedDict):
|
|||
sampling_callback: ReadOnly[SamplingFnT | None]
|
||||
elicitation_callback: ReadOnly[ElicitationFnT | None]
|
||||
logging_callback: ReadOnly[LoggingFnT | None]
|
||||
response_hooks: ReadOnly[tuple[Callable[[httpx2.Response], Awaitable[None]], ...]]
|
||||
|
||||
|
||||
from mcp.types import (
|
||||
|
|
@ -96,6 +97,7 @@ from litellm.constants import (
|
|||
)
|
||||
from litellm.experimental_mcp_client.tools import list_tools_with_pagination
|
||||
from litellm.llms.custom_httpx.http_handler import get_ssl_configuration
|
||||
from litellm.proxy._experimental.mcp_server.mcp_context import get_active_mcp_request_ctx
|
||||
from litellm.proxy._experimental.mcp_server.mcp_debug import capture_upstream_error_response
|
||||
from litellm.proxy._experimental.mcp_server.result_conversion import (
|
||||
age_freshness,
|
||||
|
|
@ -122,6 +124,20 @@ if TYPE_CHECKING:
|
|||
from litellm.proxy._experimental.mcp_server.legacy_callbacks import ElicitationCallback
|
||||
|
||||
|
||||
def _mcp_response_hooks() -> tuple[Callable[[httpx2.Response], Awaitable[None]], ...]:
|
||||
if get_active_mcp_request_ctx() is None:
|
||||
return (capture_upstream_error_response,)
|
||||
from litellm.proxy._experimental.mcp_server.legacy_callbacks import (
|
||||
get_scope_response,
|
||||
record_upstream_tool_authorization,
|
||||
)
|
||||
|
||||
return (
|
||||
partial(record_upstream_tool_authorization, pending=get_scope_response()),
|
||||
capture_upstream_error_response,
|
||||
)
|
||||
|
||||
|
||||
def to_basic_auth(auth_value: str) -> str:
|
||||
"""Convert auth value to Basic Auth format."""
|
||||
return base64.b64encode(auth_value.encode("utf-8")).decode()
|
||||
|
|
@ -497,6 +513,7 @@ class MCPClient:
|
|||
"sampling_callback": self._sampling_callback,
|
||||
"elicitation_callback": self._elicitation_callback,
|
||||
"logging_callback": self._logging_callback,
|
||||
"response_hooks": _mcp_response_hooks(),
|
||||
}
|
||||
|
||||
def session_settings(self) -> tuple[MCPTransport, MCPUpstreamProtocol, float, bool, bool, bool]:
|
||||
|
|
@ -721,7 +738,9 @@ class MCPClient:
|
|||
write_stream,
|
||||
read_timeout_seconds=self.timeout,
|
||||
message_handler=receive_message,
|
||||
**session_kwargs,
|
||||
sampling_callback=session_kwargs["sampling_callback"],
|
||||
elicitation_callback=session_kwargs["elicitation_callback"],
|
||||
logging_callback=session_kwargs["logging_callback"],
|
||||
)
|
||||
session: Final = await session_ctx.__aenter__()
|
||||
try:
|
||||
|
|
@ -931,7 +950,14 @@ class MCPClient:
|
|||
verify=ssl_config,
|
||||
follow_redirects=True,
|
||||
event_hooks=MappingProxyType(
|
||||
{"response": [capture_upstream_error_response], "request": [guard] if guard else []}
|
||||
{
|
||||
"response": list(
|
||||
self._session_callbacks_override["response_hooks"]
|
||||
if self._session_callbacks_override is not None
|
||||
else _mcp_response_hooks()
|
||||
),
|
||||
"request": [guard] if guard else [],
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -1582,6 +1608,7 @@ class PersistentMCPSession:
|
|||
self._initial_callbacks: Final[_SessionCallbackMap] = client.session_callbacks()
|
||||
self._current_callbacks: _SessionCallbackMap = self._initial_callbacks
|
||||
delegated_callbacks: Final[_SessionCallbackMap] = {
|
||||
"response_hooks": (self._record_response,),
|
||||
"sampling_callback": (
|
||||
self._delegate("sampling_callback")
|
||||
if self._initial_callbacks["sampling_callback"] is not None
|
||||
|
|
@ -1599,6 +1626,10 @@ class PersistentMCPSession:
|
|||
client.route_session_callbacks(delegated_callbacks)
|
||||
self._task: Final = loop.create_task(self._serve())
|
||||
|
||||
async def _record_response(self, response: httpx2.Response) -> None:
|
||||
for hook in self._current_callbacks["response_hooks"]:
|
||||
await hook(response)
|
||||
|
||||
@overload
|
||||
def _delegate(self, name: Literal["sampling_callback"]) -> SamplingFnT: ...
|
||||
|
||||
|
|
|
|||
|
|
@ -1790,7 +1790,7 @@ async def store_user_oauth_credential(
|
|||
payload["refresh_token"] = refresh_token
|
||||
if expires_at:
|
||||
payload["expires_at"] = expires_at
|
||||
if scopes:
|
||||
if scopes is not None:
|
||||
payload["scopes"] = scopes
|
||||
|
||||
# Guard against silently overwriting a BYOK credential with an OAuth token.
|
||||
|
|
@ -2123,9 +2123,7 @@ async def refresh_user_oauth_token(
|
|||
new_refresh_token: Final[str | None] = body.get("refresh_token") or refresh_token
|
||||
|
||||
raw_scope: Final = body.get("scope")
|
||||
scopes: list[str] | None = (raw_scope.split() if isinstance(raw_scope, str) and raw_scope else None) or cred.get(
|
||||
"scopes"
|
||||
)
|
||||
scopes: Final = raw_scope.split() if isinstance(raw_scope, str) else cred.get("scopes")
|
||||
|
||||
await store_user_oauth_credential(
|
||||
prisma_client=prisma_client,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
|||
import httpx
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
from mcp.client.auth.utils import get_client_metadata_scopes, union_scopes
|
||||
from mcp.shared.auth import OAuthMetadata
|
||||
from pydantic import ConfigDict, Field, SecretStr, TypeAdapter, ValidationError
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -110,6 +112,7 @@ from litellm.types.mcp import MCPAuth, MCPCredentials
|
|||
from litellm.types.mcp_server.mcp_server_manager import MCPServer, MCPTokenEndpointAuthMethod
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.proxy._experimental.mcp_server.db import OAuthCredentialPayload
|
||||
from litellm.proxy._types import LiteLLM_MCPServerTable
|
||||
|
||||
# TTL cache for upstream OAuth metadata fetched from pass-through MCP servers.
|
||||
|
|
@ -219,6 +222,8 @@ def encode_state_with_base_url(
|
|||
expected_issuer: str | None = None,
|
||||
authorization_response_iss_parameter_supported: bool = False,
|
||||
oauth_nonce: str | None = None,
|
||||
managed_scope: str | None = None,
|
||||
scope_user_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Encode the base_url, original state, and PKCE parameters using encryption.
|
||||
|
|
@ -251,6 +256,7 @@ def encode_state_with_base_url(
|
|||
"""
|
||||
state_data: Final = {
|
||||
"oauth_nonce": oauth_nonce,
|
||||
**({"managed_scope": managed_scope, "scope_user_id": scope_user_id} if managed_scope is not None else {}),
|
||||
"base_url": base_url,
|
||||
"original_state": original_state,
|
||||
"code_challenge": code_challenge,
|
||||
|
|
@ -290,6 +296,18 @@ def decode_state_hash(encrypted_state: str) -> dict:
|
|||
return state_data
|
||||
|
||||
|
||||
_MANAGED_AUTH_CODE_PREFIX: Final = "llm_scope_"
|
||||
|
||||
|
||||
class _ManagedAuthorizationCode(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
upstream_code: str = Field(min_length=1)
|
||||
mcp_server_id: str = Field(min_length=1)
|
||||
issuer: str | None = None
|
||||
requested_scope: str
|
||||
scope_user_id: str | None = None
|
||||
|
||||
|
||||
_BRIDGE_AUTH_CODE_PREFIX: Final = "llm_bcode_"
|
||||
|
||||
|
||||
|
|
@ -298,6 +316,8 @@ class _BridgeAuthorizationCode(LiteLLMBaseModel):
|
|||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
oauth_nonce: str | None = None
|
||||
requested_scope: str | None = None
|
||||
expected_issuer: str | None = None
|
||||
upstream_code: str = Field(min_length=1)
|
||||
litellm_user_id: str = Field(min_length=1)
|
||||
mcp_server_id: str = Field(min_length=1)
|
||||
|
|
@ -314,6 +334,8 @@ def seal_bridge_authorization_code(
|
|||
litellm_user_id: str,
|
||||
mcp_server_id: str,
|
||||
oauth_nonce: str | None = None,
|
||||
requested_scope: str | None = None,
|
||||
expected_issuer: str | None = None,
|
||||
) -> str:
|
||||
"""Seal the upstream authorization code and the SSO-captured litellm user into a gateway
|
||||
authorization code. The DCR client only echoes this opaque value back at the token endpoint; the
|
||||
|
|
@ -328,6 +350,8 @@ def seal_bridge_authorization_code(
|
|||
"litellm_user_id": litellm_user_id,
|
||||
"mcp_server_id": mcp_server_id,
|
||||
"oauth_nonce": oauth_nonce,
|
||||
"requested_scope": requested_scope,
|
||||
"expected_issuer": expected_issuer,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
|
|
@ -643,6 +667,7 @@ async def _store_per_user_token_server_side(
|
|||
token_response: dict[str, Any],
|
||||
identity_binding_proof: str | None = None,
|
||||
cimd_client_id: str | None = None,
|
||||
requested_scope: str | None = None,
|
||||
) -> None:
|
||||
"""Persist the OAuth token server-side and warm the Redis cache.
|
||||
|
||||
|
|
@ -667,8 +692,8 @@ async def _store_per_user_token_server_side(
|
|||
expires_in = None
|
||||
|
||||
refresh_token: Final[str | None] = token_response.get("refresh_token") or None
|
||||
raw_scope: Final = token_response.get("scope")
|
||||
scopes: Final[list | None] = raw_scope.split() if isinstance(raw_scope, str) and raw_scope else None
|
||||
raw_scope: Final[object] = token_response.get("scope", requested_scope)
|
||||
scopes: Final[list[str] | None] = raw_scope.split() if isinstance(raw_scope, str) else None
|
||||
|
||||
try:
|
||||
prisma_client: Final = get_prisma_client_or_throw("Database not connected. Cannot store per-user OAuth token.")
|
||||
|
|
@ -954,6 +979,46 @@ async def _resolve_oauth_authorization_user(
|
|||
return user_id
|
||||
|
||||
|
||||
async def _managed_authorization_scope(
|
||||
request: Request, server: MCPServer, scope: str | None, authorized_user_id: str | None
|
||||
) -> tuple[str | None, str | None]:
|
||||
from litellm.proxy._experimental.mcp_server.db import get_user_oauth_credential
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
gateway_credential: Final = authorized_user_id is None and await oauth_authorization_uses_gateway_credential(
|
||||
request
|
||||
)
|
||||
session_user: Final = (
|
||||
_session_cookie_user_id(request) if authorized_user_id is None and not gateway_credential else None
|
||||
)
|
||||
scope_user_id: Final = authorized_user_id or (
|
||||
await authorize_oauth_credential_request(request, server.server_id)
|
||||
if gateway_credential
|
||||
else session_user
|
||||
if session_user and await _user_can_reach_mcp_server(session_user, server.server_id)
|
||||
else None
|
||||
)
|
||||
prior_grant: Final = (
|
||||
await get_user_oauth_credential(prisma_client, scope_user_id, server.server_id)
|
||||
if scope_user_id and prisma_client is not None
|
||||
else None
|
||||
)
|
||||
merged_scope: Final = union_scopes(" ".join(prior_grant.get("scopes") or ()) if prior_grant else None, scope)
|
||||
selected_scope: Final = (
|
||||
get_client_metadata_scopes(
|
||||
merged_scope,
|
||||
None,
|
||||
OAuthMetadata.model_validate(server.authorization_server_metadata)
|
||||
if server.authorization_server_metadata is not None
|
||||
else None,
|
||||
["authorization_code", "refresh_token"],
|
||||
)
|
||||
if merged_scope is not None
|
||||
else None
|
||||
)
|
||||
return selected_scope, scope_user_id
|
||||
|
||||
|
||||
async def authorize_with_server(
|
||||
request: Request,
|
||||
mcp_server: MCPServer,
|
||||
|
|
@ -1029,6 +1094,17 @@ async def authorize_with_server(
|
|||
return subject
|
||||
litellm_user_id = subject
|
||||
|
||||
managed_per_user: Final = resolved_server.is_gateway_managed_oauth2 and resolved_server.needs_user_oauth_token
|
||||
initial_scope: Final = union_scopes(
|
||||
"openid" if enforce_binding else None,
|
||||
scope or (" ".join(resolved_server.scopes) if resolved_server.scopes else None),
|
||||
)
|
||||
selected_scope, scope_user_id = (
|
||||
await _managed_authorization_scope(request, resolved_server, initial_scope, litellm_user_id)
|
||||
if managed_per_user
|
||||
else (initial_scope, None)
|
||||
)
|
||||
|
||||
oauth_nonce: Final = secrets.token_urlsafe(32) if enforce_binding else None
|
||||
encoded_state: Final = encode_state_with_base_url(
|
||||
base_url=base_url,
|
||||
|
|
@ -1038,7 +1114,11 @@ async def authorize_with_server(
|
|||
code_challenge_method=code_challenge_method,
|
||||
client_redirect_uri=redirect_uri,
|
||||
litellm_user_id=litellm_user_id,
|
||||
mcp_server_id=resolved_server.server_id if (litellm_user_id or ephemeral_dcr_client) else None,
|
||||
mcp_server_id=resolved_server.server_id
|
||||
if (litellm_user_id or ephemeral_dcr_client or managed_per_user)
|
||||
else None,
|
||||
managed_scope=selected_scope if managed_per_user else None,
|
||||
scope_user_id=scope_user_id,
|
||||
dcr_client_id=ephemeral_dcr_client.client_id if ephemeral_dcr_client else None,
|
||||
dcr_client_secret=ephemeral_dcr_client.client_secret if ephemeral_dcr_client else None,
|
||||
dcr_token_endpoint_auth_method=ephemeral_dcr_client.token_endpoint_auth_method
|
||||
|
|
@ -1057,13 +1137,8 @@ async def authorize_with_server(
|
|||
}
|
||||
if oauth_nonce:
|
||||
params["nonce"] = oauth_nonce
|
||||
if scope:
|
||||
params["scope"] = scope
|
||||
elif resolved_server.scopes:
|
||||
params["scope"] = " ".join(resolved_server.scopes)
|
||||
|
||||
if enforce_binding and "openid" not in params.get("scope", "").split():
|
||||
params["scope"] = f"openid {params.get('scope', '')}".strip()
|
||||
if selected_scope:
|
||||
params["scope"] = selected_scope
|
||||
|
||||
if code_challenge:
|
||||
params["code_challenge"] = code_challenge
|
||||
|
|
@ -1090,18 +1165,11 @@ def _token_credential_source(mcp_server: MCPServer) -> CredentialSource:
|
|||
return "gateway_stored" if mcp_server.client_id or get_cimd_client_id(mcp_server) else "caller_supplied"
|
||||
|
||||
|
||||
async def _saved_cimd_refresh_client_id(
|
||||
async def _saved_refresh_credential(
|
||||
server: MCPServer, user_id: str | None, refresh_token: str | None
|
||||
) -> str | None:
|
||||
"""Reuse only the client identity bound to this caller's presented refresh grant."""
|
||||
if (
|
||||
not user_id
|
||||
or not refresh_token
|
||||
or not server.needs_user_oauth_token
|
||||
or server.auth_type != MCPAuth.oauth2
|
||||
or server.client_id
|
||||
or server.client_secret
|
||||
):
|
||||
) -> "OAuthCredentialPayload | None":
|
||||
"""Reuse only metadata bound to this caller's presented refresh grant."""
|
||||
if not user_id or not refresh_token or not server.needs_user_oauth_token or server.auth_type != MCPAuth.oauth2:
|
||||
return None
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._experimental.mcp_server.db import get_user_oauth_credential
|
||||
|
|
@ -1117,7 +1185,33 @@ async def _saved_cimd_refresh_client_id(
|
|||
stored_refresh: Final = credential.get("refresh_token")
|
||||
if not stored_refresh or not secrets.compare_digest(stored_refresh.encode(), refresh_token.encode()):
|
||||
return None
|
||||
return credential.get("cimd_client_id")
|
||||
return credential
|
||||
|
||||
|
||||
def _open_managed_authorization_code(
|
||||
code: str | None, server: MCPServer, grant_type: str, user_id: str | None
|
||||
) -> _ManagedAuthorizationCode | None:
|
||||
if not isinstance(code, str) or not code.startswith(_MANAGED_AUTH_CODE_PREFIX):
|
||||
return None
|
||||
decoded_code: Final = decrypt_value_helper(
|
||||
code[len(_MANAGED_AUTH_CODE_PREFIX) :], "managed_authorization_code", return_original_value=False
|
||||
)
|
||||
if not isinstance(decoded_code, str):
|
||||
raise HTTPException(status_code=400, detail="Invalid authorization code")
|
||||
try:
|
||||
managed_code: Final = _ManagedAuthorizationCode.model_validate_json(decoded_code)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid authorization code") from exc
|
||||
if (
|
||||
grant_type != "authorization_code"
|
||||
or not server.is_gateway_managed_oauth2
|
||||
or not server.needs_user_oauth_token
|
||||
or managed_code.mcp_server_id != server.server_id
|
||||
or managed_code.issuer != server.issuer
|
||||
or (managed_code.scope_user_id is not None and managed_code.scope_user_id != user_id)
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Authorization code does not match this OAuth flow")
|
||||
return managed_code
|
||||
|
||||
|
||||
async def exchange_token_with_server(
|
||||
|
|
@ -1159,10 +1253,18 @@ async def exchange_token_with_server(
|
|||
if resolved_server.needs_user_oauth_token or resolved_server.oauth_identity_binding is not None
|
||||
else None
|
||||
)
|
||||
cimd_client_id: Final = (
|
||||
await _saved_cimd_refresh_client_id(resolved_server, request_user_id, refresh_token)
|
||||
managed_code: Final = _open_managed_authorization_code(code, resolved_server, grant_type, request_user_id)
|
||||
authorization_code: Final = managed_code.upstream_code if managed_code else code
|
||||
|
||||
refresh_credential: Final = (
|
||||
await _saved_refresh_credential(resolved_server, request_user_id, refresh_token)
|
||||
if grant_type == "refresh_token"
|
||||
else None
|
||||
)
|
||||
cimd_client_id: Final = (
|
||||
refresh_credential.get("cimd_client_id")
|
||||
if refresh_credential and not resolved_server.client_id and not resolved_server.client_secret
|
||||
else None
|
||||
) or get_cimd_client_id(resolved_server)
|
||||
|
||||
# The id, secret, and token-endpoint auth method must come from the same source. When the
|
||||
|
|
@ -1234,7 +1336,7 @@ async def exchange_token_with_server(
|
|||
)
|
||||
else:
|
||||
refresh_ownership = None # rebind-ok: grant-specific branches assign one ownership value
|
||||
if not code:
|
||||
if not authorization_code:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="code is required for authorization_code grant",
|
||||
|
|
@ -1244,14 +1346,18 @@ async def exchange_token_with_server(
|
|||
# below uses the upstream code, and the mint binds the envelope to the recovered user. Bind the
|
||||
# sealed server to this request so a code minted for one bridge server cannot be spent at another.
|
||||
# A raw upstream code (scripted path) opens to None and the code is used as-is.
|
||||
bridge_identity = open_bridge_authorization_code(code)
|
||||
if bridge_identity is not None:
|
||||
if bridge_identity.mcp_server_id != resolved_server.server_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Authorization code was issued for a different MCP server",
|
||||
)
|
||||
code = bridge_identity.upstream_code
|
||||
bridge_identity = open_bridge_authorization_code(authorization_code)
|
||||
if bridge_identity is not None and (
|
||||
bridge_identity.mcp_server_id != resolved_server.server_id
|
||||
or (
|
||||
bridge_identity.requested_scope is not None
|
||||
and bridge_identity.expected_issuer != resolved_server.issuer
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Authorization code was issued for a different MCP server",
|
||||
)
|
||||
binding: Final = resolved_server.oauth_identity_binding
|
||||
if binding is not None and binding.mode == "enforce":
|
||||
if bridge_identity is None or not bridge_identity.oauth_nonce:
|
||||
|
|
@ -1273,7 +1379,7 @@ async def exchange_token_with_server(
|
|||
resolved_redirect_uri: Final = redirect_uri if bridge_token_relay else f"{proxy_base_url}/callback"
|
||||
token_data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"code": bridge_identity.upstream_code if bridge_identity else authorization_code,
|
||||
"redirect_uri": resolved_redirect_uri,
|
||||
**token_request.body,
|
||||
}
|
||||
|
|
@ -1353,6 +1459,16 @@ async def exchange_token_with_server(
|
|||
else None
|
||||
)
|
||||
|
||||
requested_scope: Final = (
|
||||
managed_code.requested_scope
|
||||
if managed_code is not None
|
||||
else bridge_identity.requested_scope
|
||||
if bridge_identity is not None
|
||||
else scope or " ".join(refresh_credential.get("scopes") or ())
|
||||
if refresh_credential
|
||||
else None
|
||||
)
|
||||
|
||||
# Store server-side when the server is configured for per-user OAuth and
|
||||
# the calling client has provided a valid LiteLLM identity.
|
||||
# Errors are non-fatal: the token is still returned to the client.
|
||||
|
|
@ -1379,6 +1495,7 @@ async def exchange_token_with_server(
|
|||
user_id=user_id,
|
||||
token_response=token_response,
|
||||
identity_binding_proof=binding_proof,
|
||||
requested_scope=requested_scope,
|
||||
**({"cimd_client_id": cimd_client_id} if cimd_client_id is not None else {}),
|
||||
)
|
||||
else:
|
||||
|
|
@ -1431,8 +1548,10 @@ async def exchange_token_with_server(
|
|||
result["expires_in"] = token_response["expires_in"]
|
||||
if token_response.get("refresh_token"):
|
||||
result["refresh_token"] = token_response["refresh_token"]
|
||||
if token_response.get("scope"):
|
||||
result["scope"] = token_response["scope"]
|
||||
token_fields: Final = TypeAdapter[dict[str, object]](dict[str, object]).validate_python(token_response)
|
||||
effective_scope: Final = token_fields.get("scope", requested_scope)
|
||||
if effective_scope is not None:
|
||||
result["scope"] = effective_scope
|
||||
|
||||
# RFC 6749 §5.1: token responses must not be cached.
|
||||
return JSONResponse(result, headers=TOKEN_NO_CACHE_HEADERS)
|
||||
|
|
@ -2116,6 +2235,7 @@ async def authorize_mcp_session(
|
|||
code_challenge_method: str | None = None,
|
||||
response_type: str | None = None,
|
||||
resource: str | None = None,
|
||||
scope: str | None = None,
|
||||
) -> Response:
|
||||
return aggregate_authorize(
|
||||
request=request,
|
||||
|
|
@ -2127,6 +2247,7 @@ async def authorize_mcp_session(
|
|||
response_type=response_type,
|
||||
session_user_id=_session_cookie_user_id(request),
|
||||
resource=resource,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2169,6 +2290,7 @@ async def authorize(
|
|||
response_type=response_type,
|
||||
session_user_id=_session_cookie_user_id(request),
|
||||
resource=resource,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
|
|
@ -2281,7 +2403,9 @@ async def token_endpoint(
|
|||
)
|
||||
|
||||
|
||||
async def _vendor_credential_state(user_id: str, server_id: str) -> VendorCredentialState:
|
||||
async def _vendor_credential_state(
|
||||
user_id: str, server_id: str, requested_scopes: tuple[str, ...] = ()
|
||||
) -> VendorCredentialState:
|
||||
"""Whether the gateway itself can see a live vendor credential for this user and server.
|
||||
|
||||
The one reading of "authorized" the connect page displays and the finish step enforces, so
|
||||
|
|
@ -2299,7 +2423,10 @@ async def _vendor_credential_state(user_id: str, server_id: str) -> VendorCreden
|
|||
credential: Final = await get_user_oauth_credential(prisma_client, user_id, server_id)
|
||||
except Exception: # noqa: BLE001 # a credential-read fault must fail the scoped grant closed
|
||||
return "unavailable"
|
||||
return "absent" if oauth_grant_state(credential) == "absent" else "present"
|
||||
if oauth_grant_state(credential) == "absent":
|
||||
return "absent"
|
||||
granted_scopes: Final = credential.get("scopes") if credential else None
|
||||
return "present" if set(requested_scopes).issubset(granted_scopes or ()) else "absent"
|
||||
|
||||
|
||||
@router.get("/authorize/flow")
|
||||
|
|
@ -2542,6 +2669,11 @@ async def callback(
|
|||
mcp_server_id: Final = state_data.get("mcp_server_id")
|
||||
dcr_client_id: Final = state_data.get("dcr_client_id")
|
||||
dcr_client_secret: Final = state_data.get("dcr_client_secret")
|
||||
managed_scope: Final = issuer_state.get("managed_scope")
|
||||
expected_issuer: Final = TypeAdapter[str | None](str | None).validate_python(
|
||||
issuer_state.get("expected_issuer")
|
||||
)
|
||||
scope_owner: Final = TypeAdapter[str | None](str | None).validate_python(issuer_state.get("scope_user_id"))
|
||||
forwarded_code = code
|
||||
if isinstance(litellm_user_id, str) and litellm_user_id and isinstance(mcp_server_id, str) and mcp_server_id:
|
||||
forwarded_code = seal_bridge_authorization_code(
|
||||
|
|
@ -2549,6 +2681,8 @@ async def callback(
|
|||
litellm_user_id=litellm_user_id,
|
||||
mcp_server_id=mcp_server_id,
|
||||
oauth_nonce=state_data.get("oauth_nonce"),
|
||||
requested_scope=managed_scope if isinstance(managed_scope, str) else None,
|
||||
expected_issuer=expected_issuer,
|
||||
)
|
||||
elif isinstance(dcr_client_id, str) and dcr_client_id and isinstance(mcp_server_id, str) and mcp_server_id:
|
||||
forwarded_code = seal_passthrough_authorization_code(
|
||||
|
|
@ -2561,7 +2695,23 @@ async def callback(
|
|||
),
|
||||
)
|
||||
|
||||
params = {"code": forwarded_code, "state": original_state}
|
||||
managed_code: Final = (
|
||||
_ManagedAuthorizationCode(
|
||||
upstream_code=forwarded_code,
|
||||
mcp_server_id=mcp_server_id,
|
||||
issuer=expected_issuer,
|
||||
requested_scope=managed_scope,
|
||||
scope_user_id=scope_owner,
|
||||
)
|
||||
if isinstance(managed_scope, str) and isinstance(mcp_server_id, str) and not litellm_user_id
|
||||
else None
|
||||
)
|
||||
returned_code: Final = (
|
||||
_MANAGED_AUTH_CODE_PREFIX + encrypt_value_helper(managed_code.model_dump_json())
|
||||
if managed_code is not None
|
||||
else forwarded_code
|
||||
)
|
||||
params = {"code": returned_code, "state": original_state}
|
||||
complete_returned_url = _append_query_params(redirect_uri, params)
|
||||
response = RedirectResponse(url=complete_returned_url, status_code=302)
|
||||
_clear_oauth_state_cookie(response, request, state)
|
||||
|
|
@ -2807,11 +2957,16 @@ async def _build_oauth_protected_resource_response(
|
|||
if obo_response is not None:
|
||||
return obo_response
|
||||
|
||||
resource_scopes: Final = [
|
||||
value
|
||||
for value in (mcp_server.scopes or () if mcp_server else ())
|
||||
if value != "offline_access" or not mcp_server or not mcp_server.is_gateway_managed_oauth2
|
||||
]
|
||||
if mcp_server is not None and mcp_server.advertises_gateway_authorization_server:
|
||||
return {
|
||||
"authorization_servers": [f"{request_base_url}/mcp"],
|
||||
"resource": resource_url,
|
||||
"scopes_supported": (mcp_server.scopes if mcp_server.scopes else []),
|
||||
"scopes_supported": resource_scopes,
|
||||
}
|
||||
|
||||
if mcp_server is None or mcp_server.auth_type != MCPAuth.oauth2_token_exchange:
|
||||
|
|
@ -2822,7 +2977,7 @@ async def _build_oauth_protected_resource_response(
|
|||
(f"{request_base_url}/{mcp_server_name}" if mcp_server_name else f"{request_base_url}")
|
||||
],
|
||||
"resource": resource_url,
|
||||
"scopes_supported": (mcp_server.scopes if mcp_server and mcp_server.scopes else []),
|
||||
"scopes_supported": resource_scopes,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ from typing import Final
|
|||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import encode_upstream_scope
|
||||
|
||||
|
||||
class MCPServerURLCredentialsError(HTTPException):
|
||||
"""A fixed, sanitized URL-credential migration error safe for operator previews."""
|
||||
|
|
@ -37,10 +39,14 @@ class MCPUpstreamAuthError(Exception):
|
|||
status_code: int,
|
||||
www_authenticate: str | None,
|
||||
server_name: str,
|
||||
required_scope: str | None = None,
|
||||
server_id: str | None = None,
|
||||
) -> None:
|
||||
self.status_code = status_code
|
||||
self.www_authenticate = www_authenticate
|
||||
self.server_name = server_name
|
||||
self.required_scope = required_scope
|
||||
self.server_id = server_id
|
||||
super().__init__(f"Upstream MCP server {server_name!r} returned {status_code}")
|
||||
|
||||
def to_http_exception(
|
||||
|
|
@ -73,19 +79,39 @@ class MCPUpstreamAuthError(Exception):
|
|||
the client originally targeted, matching the path-aware behaviour of
|
||||
``get_passthrough_resource_metadata_url`` in ``oauth_utils.py``.
|
||||
"""
|
||||
aggregate_step_up: Final = (
|
||||
self.required_scope is not None
|
||||
and request_path is not None
|
||||
and (request_path.rstrip("/") == "/mcp" or request_path.startswith("/toolset/"))
|
||||
)
|
||||
requested_scope: Final = (
|
||||
" ".join(
|
||||
encode_upstream_scope(self.server_id or self.server_name, item) for item in self.required_scope.split()
|
||||
)
|
||||
if aggregate_step_up and self.required_scope
|
||||
else self.required_scope
|
||||
)
|
||||
challenge: str | None = self.www_authenticate
|
||||
if challenge is None and self.status_code == 401 and base_url:
|
||||
if challenge is None and (self.status_code == 401 or self.required_scope is not None) and base_url:
|
||||
prefix: Final = base_url.rstrip("/")
|
||||
if request_path and request_path.startswith(f"/{self.server_name}/mcp"):
|
||||
if aggregate_step_up:
|
||||
resource_metadata_url = f"{prefix}/.well-known/oauth-protected-resource"
|
||||
elif request_path and request_path.startswith(f"/{self.server_name}/mcp"):
|
||||
resource_metadata_url = f"{prefix}/.well-known/oauth-protected-resource/{self.server_name}/mcp"
|
||||
else:
|
||||
resource_metadata_url = f"{prefix}/.well-known/oauth-protected-resource/mcp/{self.server_name}"
|
||||
challenge = f'Bearer resource_metadata="{resource_metadata_url}"'
|
||||
scoped_challenge: Final = (
|
||||
(f'{challenge}, error="insufficient_scope"' if challenge else 'Bearer error="insufficient_scope"')
|
||||
+ (f', scope="{requested_scope}"' if requested_scope else "")
|
||||
if self.required_scope is not None
|
||||
else challenge
|
||||
)
|
||||
detail: Final = "Forbidden" if self.status_code == 403 else "Unauthorized"
|
||||
return HTTPException(
|
||||
status_code=self.status_code,
|
||||
detail=detail,
|
||||
headers={"www-authenticate": challenge} if challenge else None,
|
||||
headers={"www-authenticate": scoped_challenge} if scoped_challenge else None,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,13 +10,15 @@ becomes an outcome, never a second failure.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from typing import Final, Literal, NamedTuple, NoReturn, TypeAlias
|
||||
from urllib.request import parse_http_list, parse_keqv_list
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
from mcp.types import Tool as MCPTool
|
||||
from pydantic import ConfigDict
|
||||
from pydantic import ConfigDict, TypeAdapter
|
||||
from typing_extensions import assert_never
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.exceptions import (
|
||||
|
|
@ -98,6 +100,29 @@ def upstream_auth_challenge(exc: BaseException) -> tuple[int, str | None] | None
|
|||
return None
|
||||
|
||||
|
||||
def upstream_insufficient_scope(exc: BaseException) -> str | None:
|
||||
auth: Final = upstream_auth_challenge(exc)
|
||||
if auth is None or auth[0] != 403 or not auth[1]:
|
||||
return None
|
||||
scheme, _, parameters = auth[1].partition(" ")
|
||||
if scheme.lower() != "bearer":
|
||||
return None
|
||||
fields: Final = parse_http_list(parameters)
|
||||
if any("=" not in field or not field.split("=", 1)[1] for field in fields):
|
||||
return None
|
||||
keys: Final = tuple(field.split("=", 1)[0].strip().lower() for field in fields)
|
||||
if len(set(keys)) != len(keys) or any(re.fullmatch(r"[!#$%&'*+.^_`|~0-9a-z-]+", key) is None for key in keys):
|
||||
return None
|
||||
parsed: Final = TypeAdapter(dict[str, str]).validate_python(parse_keqv_list(fields))
|
||||
values: Final = {key: value for key, value in zip(keys, parsed.values())}
|
||||
if values.get("error") != "insufficient_scope":
|
||||
return None
|
||||
scope: Final = values.get("scope", "")
|
||||
if scope and re.fullmatch(r"[!#-\[\]-~]+(?: [!#-\[\]-~]+)*", scope) is None:
|
||||
return None
|
||||
return scope
|
||||
|
||||
|
||||
def raise_classified_list_failure(
|
||||
exc: BaseException,
|
||||
server_name: str,
|
||||
|
|
|
|||
|
|
@ -59,8 +59,11 @@ from litellm.caching.caching import DualCache
|
|||
from litellm.proxy._experimental.mcp_server.catalog import catalog_operation, global_manager
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import (
|
||||
TOKEN_NO_CACHE_HEADERS,
|
||||
UPSTREAM_SCOPE_PREFIX,
|
||||
UpstreamScope,
|
||||
canonical_resource_uri,
|
||||
canonicalize_url_identity,
|
||||
decode_upstream_scope,
|
||||
get_request_base_url,
|
||||
is_loopback_redirect_host,
|
||||
validate_redirect_uri_shape,
|
||||
|
|
@ -250,7 +253,9 @@ class ConsentTeam(LiteLLMBaseModel):
|
|||
class LookupVendorCredential(Protocol):
|
||||
"""Injected read of a user's vendor credential for one server."""
|
||||
|
||||
def __call__(self, user_id: str, server_id: str, /) -> Awaitable[VendorCredentialState]: ...
|
||||
def __call__(
|
||||
self, user_id: str, server_id: str, requested_scopes: tuple[str, ...] = (), /
|
||||
) -> Awaitable[VendorCredentialState]: ...
|
||||
|
||||
|
||||
class LookupServerReachability(Protocol):
|
||||
|
|
@ -273,7 +278,9 @@ async def _refuse_subject_token(subject_token: str, request: Request) -> Subject
|
|||
)
|
||||
|
||||
|
||||
async def _unavailable_vendor_credential(user_id: str, server_id: str) -> VendorCredentialState:
|
||||
async def _unavailable_vendor_credential(
|
||||
user_id: str, server_id: str, requested_scopes: tuple[str, ...] = ()
|
||||
) -> VendorCredentialState:
|
||||
return "unavailable"
|
||||
|
||||
|
||||
|
|
@ -310,6 +317,8 @@ class _ConnectFlow(LiteLLMBaseModel):
|
|||
exp: int
|
||||
resource_server_id: str | None = None
|
||||
audience: SessionAudience | None = None
|
||||
requested_scopes: tuple[str, ...] = ()
|
||||
upstream_scopes: tuple[UpstreamScope, ...] = ()
|
||||
|
||||
|
||||
class _GatewayAuthCode(LiteLLMBaseModel):
|
||||
|
|
@ -555,6 +564,7 @@ def aggregate_authorize(
|
|||
response_type: str | None,
|
||||
session_user_id: str | None,
|
||||
resource: str | None = None,
|
||||
scope: str | None = None,
|
||||
) -> Response:
|
||||
"""The aggregate authorize verb: validate the client, require S256 PKCE, interpose
|
||||
LiteLLM sign-in, and hand the browser to the connect page with the flow sealed into a
|
||||
|
|
@ -580,6 +590,13 @@ def aggregate_authorize(
|
|||
if session_user_id is None:
|
||||
return _login_redirect(base_url, request)
|
||||
scoped_server: Final = resolve_scoped_resource_server(request, resource)
|
||||
scope_tokens: Final = tuple(token for token in (scope or "").split() if token.startswith(UPSTREAM_SCOPE_PREFIX))
|
||||
decoded_scopes: Final = tuple(decode_upstream_scope(token) for token in scope_tokens)
|
||||
if any(item is None for item in decoded_scopes):
|
||||
return _oauth_error(400, "invalid_scope", "invalid upstream permission scope")
|
||||
upstream_scopes: Final = tuple(item for item in decoded_scopes if item is not None)
|
||||
if scoped_server is not None and any(item.server_id != scoped_server.server_id for item in upstream_scopes):
|
||||
return _oauth_error(400, "invalid_scope", "upstream permission does not match the requested resource")
|
||||
handle: Final = secrets.token_urlsafe(24)
|
||||
flow: Final = _new_connect_flow(
|
||||
session_user_id=session_user_id,
|
||||
|
|
@ -589,6 +606,8 @@ def aggregate_authorize(
|
|||
code_challenge=code_challenge or "",
|
||||
resource_server_id=scoped_server.server_id if scoped_server is not None else None,
|
||||
audience=None,
|
||||
requested_scopes=tuple((scope or "").split()) if scoped_server is not None and not upstream_scopes else (),
|
||||
upstream_scopes=upstream_scopes,
|
||||
)
|
||||
connect_url: Final = _append_query_params(f"{base_url}/ui/connect", (("connect_flow", handle),))
|
||||
response: Final = RedirectResponse(connect_url, status_code=303)
|
||||
|
|
@ -748,6 +767,8 @@ def _new_connect_flow(
|
|||
code_challenge: str,
|
||||
resource_server_id: str | None,
|
||||
audience: SessionAudience | None,
|
||||
requested_scopes: tuple[str, ...] = (),
|
||||
upstream_scopes: tuple[UpstreamScope, ...] = (),
|
||||
) -> _ConnectFlow:
|
||||
now: Final = datetime.now(timezone.utc)
|
||||
return _ConnectFlow(
|
||||
|
|
@ -760,6 +781,8 @@ def _new_connect_flow(
|
|||
exp=int(now.timestamp()) + CONNECT_FLOW_TTL_SECONDS,
|
||||
resource_server_id=resource_server_id,
|
||||
audience=audience,
|
||||
requested_scopes=requested_scopes,
|
||||
upstream_scopes=upstream_scopes,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -815,20 +838,20 @@ def _open_flow_for(
|
|||
|
||||
@catalog_operation(global_manager)
|
||||
async def _flow_target(
|
||||
flow: _ConnectFlow, lookup_server_reachability: LookupServerReachability
|
||||
user_id: str, server_id: str | None, lookup_server_reachability: LookupServerReachability
|
||||
) -> tuple[Literal["unscoped", "interactive", "m2m", "stale"], MCPServer | None]:
|
||||
if flow.resource_server_id is None:
|
||||
if server_id is None:
|
||||
return "unscoped", None
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # import cycle
|
||||
MCPServerManager,
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
|
||||
server: Final = global_mcp_server_manager.get_mcp_server_by_id(flow.resource_server_id)
|
||||
server: Final = global_mcp_server_manager.get_mcp_server_by_id(server_id)
|
||||
if (
|
||||
server is None
|
||||
or not (server.is_gateway_managed_oauth2 or server.advertises_gateway_authorization_server)
|
||||
or not await lookup_server_reachability(flow.user_id, server.server_id)
|
||||
or not await lookup_server_reachability(user_id, server.server_id)
|
||||
):
|
||||
return "stale", None
|
||||
state: Final = (
|
||||
|
|
@ -847,6 +870,7 @@ class ConnectFlowDescription(TypedDict):
|
|||
server_id: ReadOnly[str | None]
|
||||
server_name: ReadOnly[str | None]
|
||||
connected: ReadOnly[bool | None]
|
||||
requested_scopes: NotRequired[ReadOnly[tuple[str, ...]]]
|
||||
|
||||
|
||||
async def _describe_opened_flow(
|
||||
|
|
@ -854,19 +878,11 @@ async def _describe_opened_flow(
|
|||
lookup_vendor_credential: LookupVendorCredential,
|
||||
lookup_server_reachability: LookupServerReachability,
|
||||
) -> ConnectFlowDescription | Response:
|
||||
state, server = await _flow_target(flow, lookup_server_reachability)
|
||||
if flow.upstream_scopes:
|
||||
return await _describe_upstream_scope_flow(flow, lookup_vendor_credential, lookup_server_reachability)
|
||||
state, server = await _flow_target(flow.user_id, flow.resource_server_id, lookup_server_reachability)
|
||||
if state == "interactive" and server is not None:
|
||||
credential: Final = await lookup_vendor_credential(flow.user_id, server.server_id)
|
||||
if credential == "unavailable":
|
||||
return _oauth_error(503, "temporarily_unavailable", _DB_UNAVAILABLE_DESCRIPTION)
|
||||
interactive_description: Final[ConnectFlowDescription] = {
|
||||
"state": state,
|
||||
"client_origin": _origin_only(flow.redirect_uri),
|
||||
"server_id": server.server_id,
|
||||
"server_name": server.server_name or server.alias or server.name,
|
||||
"connected": credential == "present",
|
||||
}
|
||||
return interactive_description
|
||||
return await _describe_interactive_target(flow, server, flow.requested_scopes, lookup_vendor_credential)
|
||||
described: Final[ConnectFlowDescription] = {
|
||||
"state": state,
|
||||
"client_origin": _origin_only(flow.redirect_uri),
|
||||
|
|
@ -877,6 +893,70 @@ async def _describe_opened_flow(
|
|||
return described
|
||||
|
||||
|
||||
async def _describe_interactive_target(
|
||||
flow: _ConnectFlow,
|
||||
server: MCPServer,
|
||||
scopes: tuple[str, ...],
|
||||
lookup_vendor_credential: LookupVendorCredential,
|
||||
) -> ConnectFlowDescription | Response:
|
||||
credential: Final = (
|
||||
await lookup_vendor_credential(flow.user_id, server.server_id, scopes)
|
||||
if scopes
|
||||
else await lookup_vendor_credential(flow.user_id, server.server_id)
|
||||
)
|
||||
if credential == "unavailable":
|
||||
return _oauth_error(503, "temporarily_unavailable", _DB_UNAVAILABLE_DESCRIPTION)
|
||||
described: Final[ConnectFlowDescription] = {
|
||||
"state": "interactive",
|
||||
"client_origin": _origin_only(flow.redirect_uri),
|
||||
"server_id": server.server_id,
|
||||
"server_name": server.server_name or server.alias or server.name,
|
||||
"connected": credential == "present",
|
||||
}
|
||||
return {**described, "requested_scopes": scopes} if scopes else described
|
||||
|
||||
|
||||
async def _describe_upstream_scope_flow(
|
||||
flow: _ConnectFlow,
|
||||
lookup_vendor_credential: LookupVendorCredential,
|
||||
lookup_server_reachability: LookupServerReachability,
|
||||
) -> ConnectFlowDescription | Response:
|
||||
server_ids: Final = tuple(dict.fromkeys(item.server_id for item in flow.upstream_scopes))
|
||||
targets: Final = tuple(
|
||||
[await _flow_target(flow.user_id, server_id, lookup_server_reachability) for server_id in server_ids]
|
||||
)
|
||||
if any(state != "interactive" or server is None for state, server in targets):
|
||||
return _oauth_error(400, "invalid_scope", "an upstream permission target is unavailable")
|
||||
descriptions: Final = tuple(
|
||||
[
|
||||
await _describe_interactive_target(
|
||||
flow,
|
||||
server,
|
||||
tuple(
|
||||
dict.fromkeys(
|
||||
item.permission for item in flow.upstream_scopes if item.server_id == server.server_id
|
||||
)
|
||||
),
|
||||
lookup_vendor_credential,
|
||||
)
|
||||
for _, server in targets
|
||||
if server is not None
|
||||
]
|
||||
)
|
||||
for description in descriptions:
|
||||
if isinstance(description, Response) or not description["connected"]:
|
||||
return description
|
||||
if flow.resource_server_id is not None:
|
||||
return descriptions[0]
|
||||
return {
|
||||
"state": "unscoped",
|
||||
"client_origin": _origin_only(flow.redirect_uri),
|
||||
"server_id": None,
|
||||
"server_name": None,
|
||||
"connected": None,
|
||||
}
|
||||
|
||||
|
||||
async def describe_connect_flow(
|
||||
request: Request,
|
||||
flow_handle: str,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import Final, Protocol
|
||||
|
||||
import httpx2
|
||||
from mcp.client.session import ClientRequestContext
|
||||
from mcp.types import (
|
||||
CreateMessageRequestParams,
|
||||
|
|
@ -14,6 +15,11 @@ from mcp.types import (
|
|||
from litellm.constants import MCP_CLIENT_TIMEOUT
|
||||
from litellm.proxy._experimental.mcp_server.contracts import OperationContext
|
||||
from litellm.proxy._experimental.mcp_server.mcp_context import get_active_mcp_request_ctx
|
||||
from litellm.proxy._experimental.mcp_server.scope_challenge import (
|
||||
OAuthScopeResponse,
|
||||
is_tool_call_request,
|
||||
scope_response_from_request,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
|
||||
|
|
@ -100,3 +106,19 @@ def create_elicitation_callback(timeout: float | None = None) -> ElicitationCall
|
|||
)
|
||||
|
||||
return callback
|
||||
|
||||
|
||||
def get_scope_response() -> OAuthScopeResponse | None:
|
||||
context: Final = get_active_mcp_request_ctx()
|
||||
return scope_response_from_request(context.request) if context is not None else None
|
||||
|
||||
|
||||
async def record_upstream_tool_authorization(response: httpx2.Response, *, pending: OAuthScopeResponse | None) -> None:
|
||||
if pending is None or not pending.pending or not response.is_success or response.request.method != "POST":
|
||||
return
|
||||
try:
|
||||
is_tool_call: Final = is_tool_call_request(response.request.content)
|
||||
except httpx2.RequestNotRead:
|
||||
return
|
||||
if is_tool_call:
|
||||
pending.allow()
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import httpx2
|
|||
from fastapi import HTTPException
|
||||
from httpx import HTTPStatusError
|
||||
from mcp import ReadResourceResult, Resource
|
||||
from mcp.shared.auth import OAuthMetadata
|
||||
from mcp.types import CallToolRequestParams as MCPCallToolRequestParams
|
||||
from mcp.types import (
|
||||
CallToolResult,
|
||||
|
|
@ -57,7 +58,7 @@ from mcp.types import (
|
|||
ResourceTemplate,
|
||||
)
|
||||
from mcp.types import Tool as MCPTool
|
||||
from pydantic import AnyUrl, Field, SecretStr, TypeAdapter
|
||||
from pydantic import AnyUrl, Field, SecretStr, TypeAdapter, ValidationError
|
||||
from typing_extensions import ReadOnly, assert_never
|
||||
|
||||
import litellm
|
||||
|
|
@ -103,9 +104,10 @@ from litellm.proxy._experimental.mcp_server.faults.list_outcomes import (
|
|||
ServerListFault,
|
||||
raise_classified_list_failure,
|
||||
upstream_auth_challenge,
|
||||
upstream_insufficient_scope,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.interactions import bind_target
|
||||
from litellm.proxy._experimental.mcp_server.legacy_callbacks import has_active_mcp_request_context
|
||||
from litellm.proxy._experimental.mcp_server.legacy_callbacks import get_scope_response, has_active_mcp_request_context
|
||||
from litellm.proxy._experimental.mcp_server.mcp_debug import describe_upstream_http_failure, record_auth_resolution
|
||||
from litellm.proxy._experimental.mcp_server.oauth2_token_cache import (
|
||||
MCPPerUserTokenCache,
|
||||
|
|
@ -895,6 +897,10 @@ def carry_forward_resolved_oauth_endpoints(new_server: MCPServer, previous_serve
|
|||
new_server.authorization_response_iss_parameter_supported = ( # rebind-ok: publish on the existing rebuild object
|
||||
previous_server.authorization_response_iss_parameter_supported
|
||||
)
|
||||
if may_carry and new_server.issuer == previous_server.issuer and new_server.authorization_server_metadata is None:
|
||||
new_server.authorization_server_metadata = ( # rebind-ok: publish capability on the existing rebuild object
|
||||
previous_server.authorization_server_metadata
|
||||
)
|
||||
if new_server.authorization_url is None and previous_server.authorization_url:
|
||||
new_server.authorization_url = previous_server.authorization_url
|
||||
if may_carry and new_server.token_url is None and previous_server.token_url:
|
||||
|
|
@ -930,7 +936,12 @@ def _restrict_discovery_to_corroborated_authorization_server(
|
|||
return metadata
|
||||
if _endpoints_corroborate_authorization_url(metadata.authorization_url, manual_authorization_url):
|
||||
return metadata
|
||||
if not metadata.token_url and not metadata.registration_url and not metadata.client_id_metadata_document_supported:
|
||||
if (
|
||||
not metadata.token_url
|
||||
and not metadata.registration_url
|
||||
and not metadata.client_id_metadata_document_supported
|
||||
and metadata.authorization_server_metadata is None
|
||||
):
|
||||
return metadata
|
||||
bridge_note: Final = (
|
||||
" The discovered registration_url is rejected with it, so this dcr_bridge server stays on the"
|
||||
|
|
@ -949,7 +960,12 @@ def _restrict_discovery_to_corroborated_authorization_server(
|
|||
bridge_note,
|
||||
)
|
||||
return metadata.model_copy(
|
||||
update={"token_url": None, "registration_url": None, "client_id_metadata_document_supported": False}
|
||||
update={
|
||||
"token_url": None,
|
||||
"registration_url": None,
|
||||
"client_id_metadata_document_supported": False,
|
||||
"authorization_server_metadata": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2086,6 +2102,7 @@ class MCPServerManager:
|
|||
return server.model_copy(
|
||||
update={
|
||||
"scopes": server.scopes or metadata.scopes,
|
||||
"authorization_server_metadata": metadata.authorization_server_metadata,
|
||||
"issuer": server.issuer or discovered_issuer,
|
||||
"client_id_metadata_document_supported": metadata.client_id_metadata_document_supported,
|
||||
"authorization_response_iss_parameter_supported": (
|
||||
|
|
@ -2678,6 +2695,9 @@ class MCPServerManager:
|
|||
scopes=resolved_scopes,
|
||||
configured_scopes=tuple(configured_scopes) if configured_scopes else None,
|
||||
issuer=effective_issuer,
|
||||
authorization_server_metadata=(
|
||||
gated_oauth_metadata.authorization_server_metadata if gated_oauth_metadata else None
|
||||
),
|
||||
client_id_metadata_document_supported=(
|
||||
gated_oauth_metadata.client_id_metadata_document_supported if gated_oauth_metadata else None
|
||||
),
|
||||
|
|
@ -3259,6 +3279,9 @@ class MCPServerManager:
|
|||
scopes=resolved_scopes,
|
||||
configured_scopes=configured_scopes,
|
||||
issuer=effective_issuer,
|
||||
authorization_server_metadata=(
|
||||
gated_oauth_metadata.authorization_server_metadata if gated_oauth_metadata else None
|
||||
),
|
||||
client_id_metadata_document_supported=(
|
||||
gated_oauth_metadata.client_id_metadata_document_supported if gated_oauth_metadata else None
|
||||
),
|
||||
|
|
@ -5383,7 +5406,16 @@ class MCPServerManager:
|
|||
continue
|
||||
|
||||
scopes = self._extract_scopes(data.get("scopes_supported"))
|
||||
try:
|
||||
authorization_metadata = OAuthMetadata.model_validate(data)
|
||||
except ValidationError:
|
||||
authorization_metadata = None
|
||||
metadata = MCPOAuthMetadata(
|
||||
authorization_server_metadata=(
|
||||
TypeAdapter(dict[str, object]).validate_python(authorization_metadata.model_dump(mode="json"))
|
||||
if authorization_metadata is not None
|
||||
else None
|
||||
),
|
||||
scopes=scopes,
|
||||
authorization_url=data.get("authorization_endpoint"),
|
||||
token_url=data.get("token_endpoint"),
|
||||
|
|
@ -6491,11 +6523,12 @@ class MCPServerManager:
|
|||
# oauth2 + delegate_auth_to_upstream (is_oauth_passthrough) is being removed, so it is not
|
||||
# added here even though the list path still relays for it.
|
||||
relays_upstream_auth: Final = mcp_server.is_client_forwarded_token
|
||||
managed_per_user: Final = mcp_server.is_gateway_managed_oauth2 and mcp_server.needs_user_oauth_token
|
||||
server_label: Final = mcp_server.name or mcp_server.server_name or mcp_server.alias or ""
|
||||
|
||||
async def _call_tool_via_client(client, params):
|
||||
async with self._outbound_limit(mcp_server, persistent_session):
|
||||
if not relays_upstream_auth:
|
||||
if not relays_upstream_auth and not managed_per_user:
|
||||
return await client.call_tool(
|
||||
params,
|
||||
host_progress_callback=host_progress_callback,
|
||||
|
|
@ -6520,8 +6553,17 @@ class MCPServerManager:
|
|||
persistent_session=persistent_session,
|
||||
)
|
||||
except Exception as e:
|
||||
required_scope: Final = upstream_insufficient_scope(e) if managed_per_user else None
|
||||
if required_scope is not None:
|
||||
raise MCPUpstreamAuthError(
|
||||
status_code=403,
|
||||
www_authenticate=None,
|
||||
server_name=mcp_server.alias or server_label,
|
||||
required_scope=required_scope,
|
||||
server_id=mcp_server.server_id,
|
||||
) from e
|
||||
auth_info: Final = _extract_upstream_auth_failure(e)
|
||||
if auth_info is None or auth_info[0] != 401:
|
||||
if not relays_upstream_auth or auth_info is None or auth_info[0] != 401:
|
||||
# A genuine (non-auth or 403-forbidden) upstream/transport failure.
|
||||
# raise_on_error demoted the client-layer log to debug, so surface it here at
|
||||
# warning level to keep the outage visible; the caller still gets the graceful
|
||||
|
|
@ -6866,6 +6908,15 @@ class MCPServerManager:
|
|||
)
|
||||
tasks.append(during_hook_task)
|
||||
|
||||
scope_response: Final = get_scope_response()
|
||||
if scope_response is not None and not (
|
||||
mcp_server.is_gateway_managed_oauth2
|
||||
and mcp_server.needs_user_oauth_token
|
||||
and not mcp_server.spec_path
|
||||
and mcp_server.transport in (MCPTransport.http, MCPTransport.sse)
|
||||
):
|
||||
scope_response.allow()
|
||||
|
||||
caller_oauth2_headers: Final = oauth2_headers
|
||||
oauth2_headers = await self._resolve_oauth2_headers_for_tool_call(mcp_server, oauth2_headers, user_api_key_auth)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
(BYOK + discoverable / pass-through OAuth proxy)."""
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from ipaddress import ip_address
|
||||
from typing import TYPE_CHECKING, Final, NoReturn
|
||||
from urllib.parse import ParseResult, urlparse, urlsplit, urlunparse, urlunsplit
|
||||
|
|
@ -819,3 +821,32 @@ def build_upstream_oauth2_token_request(
|
|||
if not resource:
|
||||
return client_auth
|
||||
return TokenEndpointClientAuth(headers=client_auth.headers, body={**client_auth.body, "resource": resource})
|
||||
|
||||
|
||||
UPSTREAM_SCOPE_PREFIX: Final = "litellm:mcp_scope:"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UpstreamScope:
|
||||
server_id: str
|
||||
permission: str
|
||||
|
||||
|
||||
def encode_upstream_scope(server_id: str, permission: str) -> str:
|
||||
return f"{UPSTREAM_SCOPE_PREFIX}{server_id.encode().hex()}:{permission.encode().hex()}"
|
||||
|
||||
|
||||
def decode_upstream_scope(token: str) -> UpstreamScope | None:
|
||||
encoded: Final = token.removeprefix(UPSTREAM_SCOPE_PREFIX).split(":")
|
||||
if not token.startswith(UPSTREAM_SCOPE_PREFIX) or len(encoded) != 2:
|
||||
return None
|
||||
try:
|
||||
server_id: Final = bytes.fromhex(encoded[0]).decode()
|
||||
permission: Final = bytes.fromhex(encoded[1]).decode()
|
||||
except (ValueError, UnicodeError):
|
||||
return None
|
||||
if not server_id or re.fullmatch(r"[!#-\[\]-~]+", permission) is None:
|
||||
return None
|
||||
if encode_upstream_scope(server_id, permission) != token:
|
||||
return None
|
||||
return UpstreamScope(server_id, permission)
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ from litellm.proxy._experimental.mcp_server.interactions import (
|
|||
seal_continuation,
|
||||
target_digest,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.legacy_callbacks import get_scope_response
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: F401 # legacy module exports
|
||||
MCPServerManager,
|
||||
_caller_authorization_fans_out, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
|
||||
|
|
@ -3009,11 +3010,12 @@ async def _execute_mcp_server_tool_call(
|
|||
is_error=True,
|
||||
)
|
||||
except MCPUpstreamAuthError as e:
|
||||
# The MCP session manager serializes handler exceptions as JSON-RPC errors, so a
|
||||
# mid-session tool call cannot emit a raw 401 + WWW-Authenticate the way the REST
|
||||
# call path and the connect-time preemptive check do. Return an explicit isError
|
||||
# naming the upstream status (at info level, not a traceback) so the client still
|
||||
# learns it must re-authenticate upstream and expected pass-through 401s don't spam.
|
||||
scope_response: Final = get_scope_response()
|
||||
if scope_response is not None:
|
||||
scope_response.deny(e)
|
||||
# The request-owned response adapter relays managed scope challenges before
|
||||
# Streamable HTTP headers commit. Other auth failures retain the existing
|
||||
# explicit tool error, including failures after streaming has begun.
|
||||
verbose_logger.info("Upstream auth failure calling MCP tool: HTTP %s", e.status_code)
|
||||
return CallToolResult(
|
||||
content=[
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ def _parse_expires_in(raw: object) -> int | None:
|
|||
|
||||
|
||||
def _parse_scopes(raw: object) -> tuple[str, ...] | None:
|
||||
return tuple(raw.split()) if isinstance(raw, str) and raw else None
|
||||
return tuple(raw.split()) if isinstance(raw, str) else None
|
||||
|
||||
|
||||
class AuthorizationCodeRefresher:
|
||||
|
|
@ -156,7 +156,8 @@ class AuthorizationCodeRefresher:
|
|||
rotated: Final = body.get("refresh_token")
|
||||
new_refresh: Final = rotated if isinstance(rotated, str) and rotated else token.refresh_token
|
||||
expires_in: Final = _parse_expires_in(body.get("expires_in"))
|
||||
scopes: Final = _parse_scopes(body.get("scope")) or token.scopes
|
||||
granted_scopes: Final = _parse_scopes(body.get("scope"))
|
||||
scopes: Final = granted_scopes if granted_scopes is not None else token.scopes
|
||||
|
||||
await self._persist(
|
||||
user_id,
|
||||
|
|
@ -164,7 +165,7 @@ class AuthorizationCodeRefresher:
|
|||
access_token,
|
||||
new_refresh,
|
||||
expires_in,
|
||||
scopes or None,
|
||||
scopes,
|
||||
**({"identity_binding_proof": binding_proof} if binding_proof is not None else {}),
|
||||
**({"cimd_client_id": cimd_client_id} if cimd_client_id is not None else {}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ async def _persist_credential(
|
|||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_in=expires_in,
|
||||
scopes=list(scopes) if scopes else None,
|
||||
scopes=list(scopes) if scopes is not None else None,
|
||||
skip_byok_guard=True,
|
||||
identity_binding_proof=identity_binding_proof,
|
||||
**({"cimd_client_id": cimd_client_id} if cimd_client_id is not None else {}),
|
||||
|
|
|
|||
93
litellm/proxy/_experimental/mcp_server/scope_challenge.py
Normal file
93
litellm/proxy/_experimental/mcp_server/scope_challenge.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""Keep upstream scope challenges ahead of Streamable HTTP response headers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.types import Message, Send
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.exceptions import MCPUpstreamAuthError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
|
||||
|
||||
SCOPE_RESPONSE_KEY: Final = "litellm.mcp.scope_response"
|
||||
_REQUEST: Final = TypeAdapter[dict[str, object]](dict[str, object])
|
||||
|
||||
|
||||
def is_tool_call_request(body: bytes) -> bool:
|
||||
try:
|
||||
return _REQUEST.validate_json(body).get("method") == "tools/call"
|
||||
except ValidationError:
|
||||
return False
|
||||
|
||||
|
||||
class OAuthScopeResponse:
|
||||
def __init__(self, send: Send, base_url: str, request_path: str | None) -> None:
|
||||
self._send = send
|
||||
self._base_url = base_url
|
||||
self._request_path = request_path
|
||||
self._ready = asyncio.Event()
|
||||
self._lock = asyncio.Lock()
|
||||
self._start: Message | None = None
|
||||
self._challenge: MCPUpstreamAuthError | None = None
|
||||
self._refused = False
|
||||
|
||||
@property
|
||||
def pending(self) -> bool:
|
||||
return not self._ready.is_set()
|
||||
|
||||
def allow(self) -> None:
|
||||
self._ready.set()
|
||||
|
||||
def deny(self, error: MCPUpstreamAuthError) -> None:
|
||||
if self.pending and error.status_code == 403 and error.required_scope is not None:
|
||||
self._challenge = error
|
||||
self._ready.set()
|
||||
|
||||
async def __call__(self, message: Message) -> None:
|
||||
if message["type"] == "http.response.start":
|
||||
self._start = message
|
||||
if message["status"] != 200:
|
||||
self.allow()
|
||||
return
|
||||
await self._ready.wait()
|
||||
async with self._lock:
|
||||
if self._refused:
|
||||
return
|
||||
if self._start is not None:
|
||||
start: Final = self._start
|
||||
self._start = None
|
||||
if self._challenge is not None:
|
||||
error: Final = self._challenge.to_http_exception(self._base_url, self._request_path)
|
||||
response: Final = JSONResponse(
|
||||
status_code=error.status_code, content={"detail": error.detail}, headers=error.headers
|
||||
)
|
||||
self._refused = True
|
||||
await self._send(
|
||||
{"type": "http.response.start", "status": response.status_code, "headers": response.raw_headers}
|
||||
)
|
||||
await self._send({"type": "http.response.body", "body": bytes(response.body)})
|
||||
return
|
||||
await self._send(start)
|
||||
await self._send(message)
|
||||
|
||||
|
||||
def scope_response_from_request(request: object) -> OAuthScopeResponse | None:
|
||||
if not isinstance(request, Request):
|
||||
return None
|
||||
response: Final = request.scope.get(SCOPE_RESPONSE_KEY)
|
||||
return response if isinstance(response, OAuthScopeResponse) else None
|
||||
|
||||
|
||||
async def finish_scope_response(context: ServerRequestContext[object, object], call_next: CallNext) -> HandlerResult:
|
||||
response: Final = scope_response_from_request(context.request)
|
||||
try:
|
||||
return await call_next(context)
|
||||
finally:
|
||||
if response is not None:
|
||||
response.allow()
|
||||
|
|
@ -72,6 +72,12 @@ from litellm.proxy._experimental.mcp_server.oauth_utils import (
|
|||
redact_mcp_resource_url,
|
||||
well_known_root_suffix,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.scope_challenge import (
|
||||
SCOPE_RESPONSE_KEY,
|
||||
OAuthScopeResponse,
|
||||
finish_scope_response,
|
||||
is_tool_call_request,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.ui_session_utils import (
|
||||
ActingUser,
|
||||
GrantedToolsetIds,
|
||||
|
|
@ -604,7 +610,7 @@ if MCP_AVAILABLE:
|
|||
name=LITELLM_MCP_SERVER_NAME,
|
||||
version=LITELLM_MCP_SERVER_VERSION,
|
||||
)
|
||||
server.middleware.append(GatewayVersionPolicy())
|
||||
server.middleware.extend((finish_scope_response, GatewayVersionPolicy()))
|
||||
server.create_initialization_options = types.MethodType(_gateway_create_initialization_options, server)
|
||||
sse: Final[SseServerTransport] = SseServerTransport("/sse/messages")
|
||||
|
||||
|
|
@ -2351,6 +2357,18 @@ if MCP_AVAILABLE:
|
|||
await rejection(scope, receive, send)
|
||||
return
|
||||
|
||||
scope_response: Final = (
|
||||
OAuthScopeResponse(
|
||||
send,
|
||||
get_request_base_url(StarletteRequest(scope)),
|
||||
get_route_relative_request_path(scope),
|
||||
)
|
||||
if is_tool_call_request(body) or len(body) >= _MCP_ROUTING_PEEK_MAX_BYTES
|
||||
else None
|
||||
)
|
||||
if scope_response is not None:
|
||||
scope[SCOPE_RESPONSE_KEY] = scope_response
|
||||
|
||||
use_stateful: Final = bool(session_id or is_initialize)
|
||||
target_manager: Final = session_manager_stateful if use_stateful else session_manager_stateless
|
||||
|
||||
|
|
@ -2468,7 +2486,7 @@ if MCP_AVAILABLE:
|
|||
touch_last_seen=(scope.get("method") or "").upper() != "DELETE",
|
||||
copy_existing_session_auth_context=is_initialize,
|
||||
)
|
||||
local_send = send
|
||||
local_send = scope_response or send
|
||||
if use_stateful and is_initialize:
|
||||
local_send = _wrap_send_with_stateful_session_auth_context(
|
||||
local_send,
|
||||
|
|
|
|||
|
|
@ -37831,6 +37831,22 @@
|
|||
],
|
||||
"title": "Resource"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "scope",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Scope"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Final, Literal
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ MCPInfo = Annotated[dict[str, Any], AfterValidator(_validate_mcp_protocol_metada
|
|||
|
||||
|
||||
class MCPOAuthMetadata(LiteLLMBaseModel):
|
||||
authorization_server_metadata: Mapping[str, object] | None = Field(default=None, exclude=True)
|
||||
scopes: list[str] | None = None
|
||||
"""Resource-driven scopes for the authorization request: the RFC 9728 protected-resource
|
||||
``scopes_supported``, or the ``scope`` from the WWW-Authenticate 401 challenge when the resource
|
||||
|
|
@ -117,6 +119,7 @@ class MCPServer(LiteLLMBaseModel):
|
|||
# scope=="user" values must be supplied per-user.
|
||||
env_vars: list[dict[str, Any]] | None = None
|
||||
# OAuth-specific fields
|
||||
authorization_server_metadata: Mapping[str, object] | None = Field(default=None, exclude=True)
|
||||
client_id: str | None = None
|
||||
client_secret: str | None = None
|
||||
issuer: str | None = None
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import textwrap
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
|
@ -23,15 +25,19 @@ from integration._support.mcp import (
|
|||
McpCaller,
|
||||
McpPeer,
|
||||
Outcome,
|
||||
ScriptedTool,
|
||||
_outcome_from_rpc,
|
||||
call_tool,
|
||||
mcp_peer,
|
||||
register_mcp,
|
||||
scripted_peer,
|
||||
text_result,
|
||||
tool_calls,
|
||||
)
|
||||
from integration._support.mcp_grants import create_toolset
|
||||
from integration._support.oauth_server import AuthorizationServer, oauth_server
|
||||
from integration._support.process import owned_proxy
|
||||
from integration._support.wire import Reply, Request, Wire, wire_server
|
||||
|
||||
ADD: Final = {"a": 2, "b": 3}
|
||||
CLIENT_REDIRECT: Final = "http://127.0.0.1:9/cb"
|
||||
|
|
@ -240,7 +246,8 @@ class _Pkce:
|
|||
|
||||
|
||||
def _authorize_through_gateway(
|
||||
gateway: Gateway, auth: AuthorizationServer, alias: str, key: str, client_id: str, pkce: _Pkce
|
||||
gateway: Gateway, auth: AuthorizationServer, alias: str, key: str, client_id: str, pkce: _Pkce,
|
||||
scope: str = "tools.call",
|
||||
) -> str:
|
||||
started: Final = gateway.client.get(
|
||||
f"/{alias}/authorize",
|
||||
|
|
@ -251,7 +258,7 @@ def _authorize_through_gateway(
|
|||
"state": "client-state",
|
||||
"code_challenge": pkce.challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"scope": "tools.call",
|
||||
"scope": scope,
|
||||
},
|
||||
headers={"x-litellm-api-key": key},
|
||||
)
|
||||
|
|
@ -575,3 +582,140 @@ def test_token_exchange_callers_with_different_subject_tokens_own_separate_listi
|
|||
assert second.list_tools().ok
|
||||
assert _echoed_description(second.call(f"{alias}-add", probe)) == "Add two integers"
|
||||
assert tool_calls(peer.drain()) == (), "a blocked probe reached the peer"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _scope_enforcing_peer(auth: AuthorizationServer) -> Iterator[tuple[McpPeer, Wire]]:
|
||||
with scripted_peer(
|
||||
ScriptedTool("read_op", lambda _: text_result("read completed")),
|
||||
ScriptedTool("write_op", lambda _: text_result("write completed")),
|
||||
ScriptedTool("forbid_op", lambda _: text_result("must not execute")),
|
||||
) as inner:
|
||||
def enforce(request: Request) -> Reply:
|
||||
body: Final = json.loads(request.body) if request.body else {}
|
||||
if body.get("method") == "tools/call":
|
||||
name: Final = body["params"]["name"]
|
||||
if name == "forbid_op":
|
||||
return Reply(status=403, body=b"denied")
|
||||
required: Final = "tools.write" if name == "write_op" else "tools.read"
|
||||
token: Final = request.headers.get("authorization", "").removeprefix("Bearer ")
|
||||
with auth.lock:
|
||||
granted: Final = auth.access_tokens.get(token, {}).get("scope", "")
|
||||
if required not in granted.split():
|
||||
return Reply(status=403, headers={"WWW-Authenticate": f'Bearer error="insufficient_scope", scope="{required}"'})
|
||||
forwarded: Final = httpx.request(request.method, inner.url, content=request.body, headers=dict(request.headers))
|
||||
return Reply(status=forwarded.status_code, body=forwarded.content, content_type=forwarded.headers.get("content-type", "application/json"))
|
||||
|
||||
with wire_server(enforce) as wire:
|
||||
yield McpPeer(wire.url + "/mcp", inner.calls), wire
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entry", ["rest", "server_mcp"])
|
||||
def test_managed_scope_step_up_preserves_read_and_write_without_retry(gateway: Gateway, entry: EntryPoint) -> None:
|
||||
with oauth_server(scopes=("tools.read", "tools.write")) as auth, _scope_enforcing_peer(auth) as (peer, wire), gateway.scenario() as scenario:
|
||||
alias: Final = "scope" + uuid.uuid4().hex[:8]
|
||||
identity: Final = _register_oauth(
|
||||
scenario, peer, auth, alias, auth_type="oauth2", oauth2_flow="authorization_code",
|
||||
credentials={"client_id": "scope-client", "client_secret": "scope-secret", "scopes": ["tools.read"]},
|
||||
)
|
||||
owner: Final = scenario.key(user_id=scenario.user(), object_permission={"mcp_servers": [identity]})
|
||||
stranger: Final = scenario.key(user_id=scenario.user(), object_permission={"mcp_servers": [identity]})
|
||||
pkce: Final = _Pkce(secrets.token_urlsafe(32))
|
||||
code: Final = _authorize_through_gateway(gateway, auth, alias, owner, "scope-client", pkce, "tools.read")
|
||||
first: Final = _redeem(gateway, alias, owner, "scope-client", code, pkce)
|
||||
assert first.status_code == 200, first.text
|
||||
caller: Final = McpCaller(gateway, owner, entry, alias)
|
||||
read: Final = caller.call(f"{alias}-read_op", {}, identity)
|
||||
assert read.ok and read.text == "read completed", read.raw
|
||||
peer.drain()
|
||||
wire.drain()
|
||||
denied: Final = (
|
||||
call_tool(gateway, owner, identity, f"{alias}-write_op", {}) if entry == "rest"
|
||||
else caller.rpc("tools/call", {"name": f"{alias}-write_op", "arguments": {}})
|
||||
)
|
||||
assert denied.status_code == 403, denied.text
|
||||
challenge: Final = denied.headers["www-authenticate"]
|
||||
assert 'error="insufficient_scope"' in challenge and 'scope="tools.write"' in challenge
|
||||
assert f'resource_metadata="{_base(gateway)}/.well-known/oauth-protected-resource/' in challenge
|
||||
attempts: Final = tuple(request for request in wire.drain() if request.body and json.loads(request.body).get("method") == "tools/call")
|
||||
assert len(attempts) == 1 and tool_calls(peer.drain()) == (), "denied operation was retried or executed"
|
||||
second_code: Final = _authorize_through_gateway(gateway, auth, alias, owner, "scope-client", pkce, "tools.write")
|
||||
second: Final = _redeem(gateway, alias, owner, "scope-client", second_code, pkce)
|
||||
assert second.status_code == 200, second.text
|
||||
with auth.lock:
|
||||
granted: Final = auth.access_tokens[_issued_token(second.json())]["scope"]
|
||||
assert granted.split() == ["tools.read", "tools.write"]
|
||||
for operation in ("read", "write"):
|
||||
result: Final = caller.call(f"{alias}-{operation}_op", {}, identity)
|
||||
assert result.ok and result.text == f"{operation} completed", result.raw
|
||||
forbidden: Final = caller.call(f"{alias}-forbid_op", {}, identity)
|
||||
assert forbidden.status == 200 and not forbidden.ok, forbidden.raw
|
||||
unauthorized: Final = McpCaller(gateway, stranger, entry, alias).call(f"{alias}-read_op", {}, identity)
|
||||
assert unauthorized.status == 401, unauthorized.raw
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["/mcp", "/toolset/{toolset}/mcp"])
|
||||
def test_managed_step_up_advertises_sdk_compatible_metadata_and_bound_permissions(gateway: Gateway, route: str) -> None:
|
||||
from mcp.shared.auth_utils import check_resource_allowed
|
||||
|
||||
with (
|
||||
oauth_server(scopes=("tools.read", "tools.write")) as auth,
|
||||
_scope_enforcing_peer(auth) as (peer, wire),
|
||||
gateway.scenario() as scenario,
|
||||
):
|
||||
alias: Final = "scope" + uuid.uuid4().hex[:8]
|
||||
identity: Final = _register_oauth(
|
||||
scenario,
|
||||
peer,
|
||||
auth,
|
||||
alias,
|
||||
auth_type="oauth2",
|
||||
oauth2_flow="authorization_code",
|
||||
credentials={"client_id": "scope-client", "client_secret": "scope-secret", "scopes": ["tools.read"]},
|
||||
)
|
||||
toolset_name: Final = "scope" + uuid.uuid4().hex[:8]
|
||||
toolset_id: Final = create_toolset(
|
||||
scenario, ((identity, "read_op"), (identity, "write_op")), toolset_name=toolset_name
|
||||
)
|
||||
owner: Final = scenario.key(
|
||||
user_id=scenario.user(), object_permission={"mcp_servers": [identity], "mcp_toolsets": [toolset_id]}
|
||||
)
|
||||
pkce: Final = _Pkce(secrets.token_urlsafe(32))
|
||||
code: Final = _authorize_through_gateway(gateway, auth, alias, owner, "scope-client", pkce, "tools.read")
|
||||
first: Final = _redeem(gateway, alias, owner, "scope-client", code, pkce)
|
||||
assert first.status_code == 200, first.text
|
||||
path: Final = route.format(toolset=toolset_name)
|
||||
headers: Final = {**ACCEPT, "Authorization": f"Bearer {owner}"}
|
||||
initialized: Final = gateway.client.post(
|
||||
path, headers=headers, json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": dict(INITIALIZE)}
|
||||
)
|
||||
assert initialized.status_code == 200, initialized.text
|
||||
peer.drain()
|
||||
wire.drain()
|
||||
response: Final = gateway.client.post(
|
||||
path,
|
||||
headers=headers,
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": {"name": f"{alias}-write_op", "arguments": {}},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 403, response.text
|
||||
challenge: Final = response.headers["www-authenticate"]
|
||||
metadata_url: Final = challenge.split('resource_metadata="', 1)[1].split('"', 1)[0]
|
||||
metadata: Final = gateway.client.get(metadata_url)
|
||||
assert metadata.status_code == 200, metadata.text
|
||||
assert check_resource_allowed(
|
||||
requested_resource=_base(gateway) + path, configured_resource=metadata.json()["resource"]
|
||||
)
|
||||
encoded_scope: Final = challenge.split('scope="', 1)[1].split('"', 1)[0]
|
||||
assert encoded_scope == f"litellm:mcp_scope:{identity.encode().hex()}:{b'tools.write'.hex()}"
|
||||
assert tool_calls(peer.drain()) == (), "denied operation executed upstream"
|
||||
attempts: Final = tuple(
|
||||
request
|
||||
for request in wire.drain()
|
||||
if request.body and json.loads(request.body).get("method") == "tools/call"
|
||||
)
|
||||
assert len(attempts) == 1, "denied operation was retried"
|
||||
|
|
|
|||
|
|
@ -2757,6 +2757,35 @@ async def test_optional_discovery_allows_exhaustion_at_page_cap(method: str, mon
|
|||
)
|
||||
|
||||
|
||||
def test_standalone_client_request_does_not_require_proxy_dependencies():
|
||||
import textwrap
|
||||
|
||||
from tests.test_litellm_rust.support.child_interpreter import run_child_interpreter
|
||||
|
||||
script = textwrap.dedent("""
|
||||
import asyncio
|
||||
import sys
|
||||
for name in ("fastapi", "orjson", "email_validator", "annotated_doc"):
|
||||
sys.modules[name] = None
|
||||
import httpx2
|
||||
from litellm.experimental_mcp_client.client import MCPClient
|
||||
|
||||
async def request():
|
||||
upstream = MCPClient(server_url="https://upstream.example/mcp")
|
||||
transport = httpx2.MockTransport(lambda request: httpx2.Response(200))
|
||||
async with upstream._create_httpx_client_factory(transport=transport)() as client:
|
||||
response = await client.post(upstream.server_url, json={"method": "tools/call"})
|
||||
assert response.status_code == 200
|
||||
assert all(sys.modules[name] is None for name in ("fastapi", "orjson", "email_validator", "annotated_doc"))
|
||||
|
||||
asyncio.run(request())
|
||||
print("standalone request succeeded")
|
||||
""")
|
||||
result = run_child_interpreter(script, timeout=60)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "standalone request succeeded"
|
||||
|
||||
|
||||
def test_client_import_before_proxy_credentials_succeeds_in_fresh_process():
|
||||
import subprocess
|
||||
|
||||
|
|
@ -4942,3 +4971,50 @@ async def test_persistent_session_ends_after_a_broken_stream_so_the_next_call_ge
|
|||
await asyncio.wait_for(session.run(broken_stream), 5)
|
||||
await asyncio.wait_for(session.wait_closed(), 5)
|
||||
assert session.closed, "a dead transport must end the session instead of being reused"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("session_started_in_request", [True, False])
|
||||
async def test_persistent_session_releases_the_current_requests_scope_response(session_started_in_request) -> None:
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from litellm.proxy._experimental.mcp_server.mcp_context import active_mcp_request_ctx_var
|
||||
from litellm.proxy._experimental.mcp_server.scope_challenge import SCOPE_RESPONSE_KEY, OAuthScopeResponse
|
||||
|
||||
app = _stateful_upstream()
|
||||
async with app.router.lifespan_context(app):
|
||||
client = _StatefulUpstreamClient(app, server_url="http://upstream/mcp", transport_type=MCPTransport.http)
|
||||
first = OAuthScopeResponse(AsyncMock(), "https://gateway.example", "/mcp")
|
||||
second = OAuthScopeResponse(AsyncMock(), "https://gateway.example", "/mcp")
|
||||
context = ServerRequestContext(
|
||||
session=MagicMock(),
|
||||
lifespan_context={},
|
||||
protocol_version="2025-11-25",
|
||||
method="tools/call",
|
||||
request=Request({"type": "http", SCOPE_RESPONSE_KEY: first}),
|
||||
)
|
||||
earlier_session = None if session_started_in_request else client.open_persistent_session()
|
||||
token = active_mcp_request_ctx_var.set(context)
|
||||
session = earlier_session if earlier_session is not None else client.open_persistent_session()
|
||||
try:
|
||||
selected = await client.call_tool(
|
||||
CallToolRequestParams(name="select_project", arguments={"name": "demo"}), persistent_session=session
|
||||
)
|
||||
assert not selected.is_error and not first.pending
|
||||
active_mcp_request_ctx_var.set(
|
||||
ServerRequestContext(
|
||||
session=context.session,
|
||||
lifespan_context={},
|
||||
protocol_version="2025-11-25",
|
||||
method="tools/call",
|
||||
request=Request({"type": "http", SCOPE_RESPONSE_KEY: second}),
|
||||
)
|
||||
)
|
||||
created = await client.call_tool(
|
||||
CallToolRequestParams(name="create_feature", arguments={"title": "feature"}), persistent_session=session
|
||||
)
|
||||
assert not created.is_error and created.content[0].text == "demo/feature"
|
||||
assert not second.pending
|
||||
finally:
|
||||
active_mcp_request_ctx_var.reset(token)
|
||||
session.close()
|
||||
await asyncio.wait_for(session.wait_closed(), 5)
|
||||
|
|
|
|||
|
|
@ -267,3 +267,32 @@ def test_pure_non_auth_response_still_classifies_upstream_error():
|
|||
fault = classify_list_exception(exc)
|
||||
assert fault.tag == "upstream_error"
|
||||
assert fault.status_code == 502
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status, challenge, expected", [
|
||||
(403, 'Bearer error="insufficient_scope", scope="tools.write"', "tools.write"),
|
||||
(403, 'bEaReR error=insufficient_scope, scope="tools.read tools.write"', "tools.read tools.write"),
|
||||
(403, 'Bearer error="insufficient_scope"', ""),
|
||||
(403, 'Bearer error="insufficient_scope", scope=', None),
|
||||
(403, 'Bearer error="insufficient_scope", scope', None),
|
||||
(401, 'Bearer error="insufficient_scope", scope="tools.write"', None),
|
||||
(403, 'Bearer error="invalid_token", scope="tools.write"', None),
|
||||
(403, '', None),
|
||||
(403, 'Basic error="insufficient_scope", scope="tools.write"', None),
|
||||
(403, 'Bearer error_description="error=insufficient_scope", scope="tools.write"', None),
|
||||
(403, 'Bearer error="insufficient_scope", error="invalid_token"', None),
|
||||
(403, 'Bearer error="insufficient_scope", scope="tools.write\r\ninjected"', None),
|
||||
(403, 'Bearer error="insufficient_scope", scope="tools.write", Basic realm="other"', None),
|
||||
])
|
||||
@pytest.mark.parametrize("client", ["httpx", "httpx2"])
|
||||
def test_managed_scope_step_up_only_accepts_unambiguous_bearer_challenges(status, challenge, expected, client):
|
||||
import httpx2
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import upstream_insufficient_scope
|
||||
|
||||
transport = httpx if client == "httpx" else httpx2
|
||||
response = transport.Response(status, headers={"www-authenticate": challenge}, request=transport.Request("POST", "https://peer.test/mcp"))
|
||||
cause = transport.HTTPStatusError("upstream rejected", request=response.request, response=response)
|
||||
wrapped = RuntimeError("transport failure")
|
||||
wrapped.__cause__ = cause
|
||||
assert upstream_insufficient_scope(wrapped) == expected
|
||||
|
|
|
|||
|
|
@ -210,16 +210,17 @@ async def test_unrotated_refresh_token_is_carried_forward():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unrecorded_scope_is_carried_forward():
|
||||
@pytest.mark.parametrize("prior_scopes", [(), ("read", "write")])
|
||||
async def test_unrecorded_scope_is_carried_forward(prior_scopes):
|
||||
persisted = []
|
||||
refresher = _refresher(body={"access_token": "new-at"}, persist_sink=persisted)
|
||||
token = await refresher.refresh(
|
||||
"a", "s", OAuthToken("old", refresh_token="rt", scopes=("read", "write"))
|
||||
"a", "s", OAuthToken("old", refresh_token="rt", scopes=prior_scopes)
|
||||
)
|
||||
assert token is not None
|
||||
# response omitted "scope" -> the user's recorded grant is preserved, not dropped
|
||||
assert token.scopes == ("read", "write")
|
||||
assert persisted[0][5] == ("read", "write")
|
||||
assert token.scopes == prior_scopes
|
||||
assert persisted[0][5] == prior_scopes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -257,18 +258,19 @@ async def test_refresh_omits_resource_when_unset():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returned_scope_overrides_prior_when_present():
|
||||
@pytest.mark.parametrize("scope, expected", [("read", ("read",)), ("", ()), (" ", ())])
|
||||
async def test_returned_scope_overrides_prior_when_present(scope, expected):
|
||||
persisted = []
|
||||
refresher = _refresher(
|
||||
body={"access_token": "new-at", "scope": "read"},
|
||||
body={"access_token": "new-at", "scope": scope},
|
||||
persist_sink=persisted,
|
||||
)
|
||||
token = await refresher.refresh(
|
||||
"a", "s", OAuthToken("old", refresh_token="rt", scopes=("read", "write"))
|
||||
)
|
||||
assert token is not None
|
||||
assert token.scopes == ("read",) # a present scope replaces the prior grant
|
||||
assert persisted[0][5] == ("read",)
|
||||
assert token.scopes == expected
|
||||
assert persisted[0][5] == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -366,7 +368,7 @@ async def test_cimd_refresh_on_fresh_replica_preserves_user_and_client_identity(
|
|||
{},
|
||||
)
|
||||
]
|
||||
assert persisted == [("alice", "srv", "new-at", "old-rt", 3600, None)]
|
||||
assert persisted == [("alice", "srv", "new-at", "old-rt", 3600, ())]
|
||||
assert server.client_id is None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,63 @@ class _RecordingStore:
|
|||
self.invalidations.append((user_id, server_id))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def credential_database(monkeypatch):
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
prisma = MagicMock()
|
||||
table = prisma.db.litellm_mcpusercredentials
|
||||
table.upsert = AsyncMock()
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "credential-persistence-test-salt")
|
||||
return table
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("scopes, expected", [(None, None), ((), []), (("read",), ["read"])])
|
||||
async def test_credential_persistence_preserves_known_and_unknown_scopes(credential_database, scopes, expected):
|
||||
from litellm.proxy._experimental.mcp_server.db import decode_oauth_payload
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.per_user_oauth_store import _persist_credential
|
||||
|
||||
await _persist_credential("alice", "srv", "access", "refresh", 60, scopes)
|
||||
row = credential_database.upsert.call_args.kwargs["data"]["create"]
|
||||
payload = decode_oauth_payload(row["credential_b64"])
|
||||
assert payload is not None and payload.get("scopes") == expected
|
||||
assert row["user_id"] == "alice" and row["server_id"] == "srv"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"prior_scopes, response_scope, expected",
|
||||
[((), None, []), (("read", "write"), "", []), (("read", "write"), None, ["read", "write"])],
|
||||
)
|
||||
async def test_native_refresh_persists_actual_grant_through_storage_adapter(
|
||||
credential_database, prior_scopes, response_scope, expected,
|
||||
):
|
||||
from litellm.proxy._experimental.mcp_server.db import decode_oauth_payload
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.authz_code_refresher import AuthorizationCodeRefresher
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.per_user_oauth_store import _persist_credential
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
server = MCPServer(
|
||||
server_id="srv", name="srv", transport="http", auth_type="oauth2",
|
||||
token_url="https://idp.example/token", client_id="client", client_secret="secret",
|
||||
)
|
||||
|
||||
async def token_endpoint(url, form, headers):
|
||||
assert url == server.token_url and form["grant_type"] == "refresh_token"
|
||||
return {"access_token": "rotated", **({"scope": response_scope} if response_scope is not None else {})}
|
||||
|
||||
refresher = AuthorizationCodeRefresher(lambda _: server, token_endpoint, _persist_credential)
|
||||
refreshed = await refresher.refresh("alice", "srv", OAuthToken("old", refresh_token="refresh", scopes=prior_scopes))
|
||||
assert refreshed is not None and refreshed.scopes == tuple(expected)
|
||||
payload = decode_oauth_payload(credential_database.upsert.call_args.kwargs["data"]["create"]["credential_b64"])
|
||||
assert payload is not None and payload.get("scopes") == expected
|
||||
assert payload["access_token"] == "rotated"
|
||||
|
||||
|
||||
class _BlockingStore:
|
||||
def __init__(self, access_token: str) -> None:
|
||||
self._access_token = access_token
|
||||
|
|
|
|||
|
|
@ -1856,3 +1856,15 @@ async def test_saved_cimd_grant_refreshes_from_encrypted_storage_on_a_fresh_repl
|
|||
assert parse_qs(upstream.calls[0].request.content.decode())["client_id"] == [identity]
|
||||
assert "client_secret" not in parse_qs(upstream.calls[0].request.content.decode())
|
||||
assert server.client_id is None and server.client_id_metadata_document_supported is capability
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("provider_scope, expected", [(None, ["a"]), ("b", ["b"]), ("", [])])
|
||||
async def test_silent_refresh_preserves_omitted_scope_but_accepts_explicit_grant(monkeypatch, provider_scope, expected):
|
||||
import litellm.proxy._experimental.mcp_server.db as db_mod
|
||||
|
||||
await _run_refresh(monkeypatch, _refresh_server(), {
|
||||
"access_token": "new-access",
|
||||
**({"scope": provider_scope} if provider_scope is not None else {}),
|
||||
})
|
||||
assert db_mod.store_user_oauth_credential.await_args.kwargs["scopes"] == expected
|
||||
|
|
|
|||
|
|
@ -11981,6 +11981,7 @@ def jwt_oauth_identity(monkeypatch: pytest.MonkeyPatch) -> tuple["JWTHandler", "
|
|||
monkeypatch.setattr(proxy_server, "user_api_key_cache", cache)
|
||||
prisma: Final = MagicMock()
|
||||
prisma.db.litellm_teammembership.find_unique = AsyncMock(return_value=None)
|
||||
prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
|
||||
return handler, signing_key
|
||||
|
||||
|
|
@ -13732,7 +13733,7 @@ async def test_token_route_preserves_saved_cimd_grant_after_origin_change(
|
|||
"matching", "unicode_grant", "foreign_refresh", "missing_refresh", "missing_grant", "database_missing",
|
||||
"database_outage", "static_client", "anonymous",
|
||||
])
|
||||
async def test_saved_cimd_refresh_identity_is_bound_to_the_callers_stored_grant(
|
||||
async def test_saved_refresh_metadata_is_bound_to_the_callers_stored_grant(
|
||||
monkeypatch: pytest.MonkeyPatch, state: str,
|
||||
) -> None:
|
||||
from types import SimpleNamespace
|
||||
|
|
@ -13766,12 +13767,210 @@ async def test_saved_cimd_refresh_identity_is_bound_to_the_callers_stored_grant(
|
|||
table.find_unique.side_effect = RuntimeError("database unavailable")
|
||||
if state == "static_client":
|
||||
server.client_id = "configured-client"
|
||||
resolved: Final = await endpoints._saved_cimd_refresh_client_id(
|
||||
resolved: Final = await endpoints._saved_refresh_credential(
|
||||
server, None if state == "anonymous" else "alice",
|
||||
"foreign-refresh" if state == "foreign_refresh" else grant,
|
||||
)
|
||||
assert resolved == (identity if state in ("matching", "unicode_grant") else None)
|
||||
if state in ("anonymous", "static_client", "database_missing"):
|
||||
if state in ("matching", "unicode_grant", "static_client"):
|
||||
assert resolved is not None and resolved["cimd_client_id"] == identity
|
||||
assert resolved["refresh_token"] == grant
|
||||
else:
|
||||
assert resolved is None
|
||||
if state in ("anonymous", "database_missing"):
|
||||
table.find_unique.assert_not_awaited()
|
||||
else:
|
||||
table.find_unique.assert_awaited_once_with(where={"user_id_server_id": {"user_id": "alice", "server_id": server.server_id}})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("prior_scopes, expected", [(["tools.read"], "tools.read tools.write"), (None, "tools.write"), ([], "tools.write")])
|
||||
async def test_managed_authorize_retains_the_callers_previous_grant(jwt_oauth_identity, monkeypatch, prior_scopes, expected):
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._experimental.mcp_server import db, discoverable_endpoints as endpoints
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
|
||||
_, signing_key = jwt_oauth_identity
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "scope-grant-test-salt")
|
||||
server = _cimd_oauth_server().model_copy(update={"client_id": "scope-client", "scopes": ["tools.read"]})
|
||||
global_mcp_server_manager.registry[server.server_id] = server
|
||||
credential = db.encrypt_value_helper(json.dumps({"type": "oauth2", "access_token": "old-access", "scopes": prior_scopes}))
|
||||
table = proxy_server.prisma_client.db.litellm_mcpusercredentials
|
||||
table.find_unique = AsyncMock(return_value=SimpleNamespace(credential_b64=credential))
|
||||
request = _token_request({"Authorization": f"Bearer {_oauth_identity_jwt(signing_key, scope='litellm_proxy_admin')}"})
|
||||
response = await endpoints.authorize_with_server(
|
||||
request, server, "scope-client", "http://127.0.0.1:9/cb", scope="tools.write",
|
||||
code_challenge="challenge", code_challenge_method="S256",
|
||||
)
|
||||
assert parse_qs(urlsplit(response.headers["location"]).query)["scope"] == [expected]
|
||||
assert table.find_unique.call_args.kwargs["where"] == {
|
||||
"user_id_server_id": {"user_id": "jwt-owner", "server_id": server.server_id}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("returned_scope, expected", [(None, ["tools.read", "tools.write"]), ("tools.read", ["tools.read"]), ("", [])])
|
||||
async def test_managed_code_exchange_preserves_requested_scope_only_when_omitted(
|
||||
jwt_oauth_identity, monkeypatch, respx_mock, returned_scope, expected,
|
||||
):
|
||||
from http.cookies import SimpleCookie
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._experimental.mcp_server import db, discoverable_endpoints as endpoints
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
|
||||
_, signing_key = jwt_oauth_identity
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "requested-scope-test-salt")
|
||||
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
||||
server = _cimd_oauth_server().model_copy(update={"client_id": "scope-client"})
|
||||
global_mcp_server_manager.registry[server.server_id] = server
|
||||
table = proxy_server.prisma_client.db.litellm_mcpusercredentials
|
||||
table.find_unique = AsyncMock(return_value=None)
|
||||
table.upsert = AsyncMock()
|
||||
request = _token_request({"Authorization": f"Bearer {_oauth_identity_jwt(signing_key, scope='litellm_proxy_admin')}"})
|
||||
started = await endpoints.authorize_with_server(
|
||||
request, server, "scope-client", "http://127.0.0.1:9/cb", scope="tools.read tools.write",
|
||||
code_challenge="challenge", code_challenge_method="S256",
|
||||
)
|
||||
state = parse_qs(urlsplit(started.headers["location"]).query)["state"][0]
|
||||
cookies = SimpleCookie()
|
||||
cookies.load(started.headers["set-cookie"])
|
||||
callback_request = _token_request({"Cookie": "; ".join(f"{name}={value.value}" for name, value in cookies.items())})
|
||||
returned = await endpoints.callback(callback_request, code="provider-code", state=state, error=None, iss=None)
|
||||
code = parse_qs(urlsplit(returned.headers["location"]).query)["code"][0]
|
||||
upstream = respx_mock.post(server.token_url).respond(200, json={
|
||||
"access_token": "new-access", "token_type": "Bearer", "refresh_token": "new-refresh",
|
||||
**({"scope": returned_scope} if returned_scope is not None else {}),
|
||||
})
|
||||
exchanged = await endpoints.exchange_token_with_server(
|
||||
request, server, "authorization_code", client_id="scope-client", code=code, code_verifier="verifier", redirect_uri="http://127.0.0.1:9/cb", client_secret=None,
|
||||
)
|
||||
assert parse_qs(upstream.calls[0].request.content.decode())["code"] == ["provider-code"]
|
||||
saved = db.decode_oauth_payload(table.upsert.call_args.kwargs["data"]["create"]["credential_b64"])
|
||||
assert saved is not None and saved.get("scopes") == expected
|
||||
assert json.loads(exchanged.body)["scope"] == " ".join(expected or ())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("advertised, expected", [(True, "tools.read offline_access"), (False, "tools.read")])
|
||||
async def test_managed_authorize_requests_only_selected_scope_and_supported_refresh_access(monkeypatch, advertised, expected):
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from mcp.shared.auth import OAuthMetadata
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
|
||||
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "metadata-scope-test-salt")
|
||||
metadata = OAuthMetadata(
|
||||
issuer="https://idp.example.com", authorization_endpoint="https://idp.example.com/authorize",
|
||||
token_endpoint="https://idp.example.com/token", response_types_supported=["code"],
|
||||
scopes_supported=["tools.read", "admin"] + (["offline_access"] if advertised else []),
|
||||
)
|
||||
server = _cimd_oauth_server().model_copy(update={"authorization_server_metadata": metadata.model_dump(mode="json")})
|
||||
response = await endpoints.authorize_with_server(
|
||||
_cimd_request(), server, "scope-client", "http://127.0.0.1:9/cb", scope="tools.read",
|
||||
code_challenge="challenge", code_challenge_method="S256",
|
||||
)
|
||||
assert parse_qs(urlsplit(response.headers["location"]).query)["scope"] == [expected]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("mismatch", ["server", "issuer", "user", "mode", "tampered", "invalid_payload"])
|
||||
async def test_managed_scope_code_cannot_cross_oauth_boundaries(jwt_oauth_identity, monkeypatch, respx_mock, mismatch):
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
|
||||
|
||||
_, signing_key = jwt_oauth_identity
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "scope-boundary-test-salt")
|
||||
server = _cimd_oauth_server().model_copy(update={"client_id": "scope-client"})
|
||||
payload = endpoints._ManagedAuthorizationCode(
|
||||
upstream_code="provider-code", mcp_server_id="other-server" if mismatch == "server" else server.server_id,
|
||||
issuer="https://other-issuer.test" if mismatch == "issuer" else server.issuer,
|
||||
requested_scope="tools.read tools.write", scope_user_id="other-user" if mismatch == "user" else "jwt-owner",
|
||||
)
|
||||
code = endpoints._MANAGED_AUTH_CODE_PREFIX + ("invalid" if mismatch == "tampered" else endpoints.encrypt_value_helper("{}" if mismatch == "invalid_payload" else payload.model_dump_json()))
|
||||
request = _token_request({"Authorization": f"Bearer {_oauth_identity_jwt(signing_key, scope='litellm_proxy_admin')}"})
|
||||
if mismatch == "mode":
|
||||
server.auth_type = MCPAuth.true_passthrough
|
||||
with pytest.raises(HTTPException) as raised:
|
||||
await endpoints.exchange_token_with_server(
|
||||
request, server, "authorization_code", code=code, redirect_uri="http://127.0.0.1:9/cb",
|
||||
client_id="scope-client", client_secret=None, code_verifier="verifier",
|
||||
)
|
||||
assert raised.value.status_code == 400
|
||||
assert respx_mock.calls.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("scope, requested_scope, expected", [
|
||||
(None, None, ["tools.read", "tools.write"]), ("tools.read", None, ["tools.read"]), ("", None, []),
|
||||
(None, "tools.write", ["tools.write"]), ("tools.read", "tools.write", ["tools.read"]), ("", "tools.write", []),
|
||||
])
|
||||
async def test_managed_refresh_retains_only_the_actual_grant(jwt_oauth_identity, monkeypatch, respx_mock, scope, requested_scope, expected):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._experimental.mcp_server import db, discoverable_endpoints as endpoints
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
|
||||
_, signing_key = jwt_oauth_identity
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "refresh-scope-test-salt")
|
||||
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
||||
server = _cimd_oauth_server().model_copy(update={"client_id": "scope-client"})
|
||||
global_mcp_server_manager.registry[server.server_id] = server
|
||||
credential = db.encrypt_value_helper(json.dumps({
|
||||
"type": "oauth2", "access_token": "old-access", "refresh_token": "owner-refresh", "scopes": ["tools.read", "tools.write"],
|
||||
}))
|
||||
table = proxy_server.prisma_client.db.litellm_mcpusercredentials
|
||||
table.find_unique = AsyncMock(return_value=SimpleNamespace(credential_b64=credential))
|
||||
table.upsert = AsyncMock()
|
||||
upstream = respx_mock.post(server.token_url).respond(200, json={
|
||||
"access_token": "new-access", "token_type": "Bearer", "refresh_token": "new-refresh",
|
||||
**({"scope": scope} if scope is not None else {}),
|
||||
})
|
||||
request = _token_request({"Authorization": f"Bearer {_oauth_identity_jwt(signing_key, scope='litellm_proxy_admin')}"})
|
||||
await endpoints.exchange_token_with_server(
|
||||
request, server, "refresh_token", code=None, redirect_uri=None, client_id="scope-client", client_secret=None,
|
||||
code_verifier=None, refresh_token="owner-refresh", scope=requested_scope,
|
||||
)
|
||||
saved = db.decode_oauth_payload(table.upsert.call_args.kwargs["data"]["create"]["credential_b64"])
|
||||
assert saved is not None and saved.get("scopes") == expected
|
||||
assert upstream.call_count == 1
|
||||
from urllib.parse import parse_qs
|
||||
sent = parse_qs(upstream.calls.last.request.content.decode())
|
||||
assert sent.get("scope") == ([requested_scope] if requested_scope else None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("per_server_relay", [False, True])
|
||||
async def test_managed_resource_metadata_excludes_refresh_permission(monkeypatch, per_server_relay):
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
|
||||
server = _cimd_oauth_server().model_copy(update={
|
||||
"scopes": ["tools.read", "offline_access"], "per_server_oauth_discovery": per_server_relay,
|
||||
})
|
||||
global_mcp_server_manager.registry[server.server_id] = server
|
||||
result = await endpoints._build_oauth_protected_resource_response(_cimd_request(), server.server_id, True)
|
||||
assert result["scopes_supported"] == ["tools.read"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("granted, required, expected", [
|
||||
(["tools.read"], ("tools.write",), "absent"),
|
||||
(["tools.read", "tools.write"], ("tools.write",), "present"),
|
||||
(None, ("tools.write",), "absent"),
|
||||
(["tools.read"], (), "present"),
|
||||
])
|
||||
async def test_connect_requires_the_requested_vendor_grant(monkeypatch, granted, required, expected):
|
||||
from litellm.proxy._experimental.mcp_server import db, discoverable_endpoints as endpoints
|
||||
|
||||
credential = {**_stored_grant(), "scopes": granted}
|
||||
read = AsyncMock(return_value=credential)
|
||||
prisma = MagicMock()
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma)
|
||||
monkeypatch.setattr(db, "get_user_oauth_credential", read)
|
||||
assert await endpoints._vendor_credential_state("owner", "server", required) == expected
|
||||
read.assert_awaited_once_with(prisma, "owner", "server")
|
||||
|
|
|
|||
|
|
@ -991,7 +991,7 @@ SCOPED_RESOURCE = "https://llm.example.com/mcp/github"
|
|||
_MANAGER_PATCH = "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager"
|
||||
|
||||
|
||||
def _scoped_authorize(client_id, resource, session_user_id="u1"):
|
||||
def _scoped_authorize(client_id, resource, session_user_id="u1", **kwargs):
|
||||
return aggregate_authorize(
|
||||
request=_request(query=f"client_id={client_id}"),
|
||||
client_id=client_id,
|
||||
|
|
@ -1002,6 +1002,7 @@ def _scoped_authorize(client_id, resource, session_user_id="u1"):
|
|||
response_type="code",
|
||||
session_user_id=session_user_id,
|
||||
resource=resource,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1914,7 +1915,9 @@ def _redis_that(async_increment, get=None):
|
|||
cache.redis_cache.async_increment = async_increment
|
||||
cache.redis_cache.check_and_fix_namespace = MagicMock(side_effect=lambda key: key)
|
||||
cache.redis_cache.init_async_client.return_value.get = get or AsyncMock(return_value=None)
|
||||
cache.redis_cache.async_get_cache = AsyncMock(side_effect=AssertionError("peek must read the client, not the wrapper"))
|
||||
cache.redis_cache.async_get_cache = AsyncMock(
|
||||
side_effect=AssertionError("peek must read the client, not the wrapper")
|
||||
)
|
||||
cache.async_increment_cache = AsyncMock(side_effect=AssertionError("must not fall back to in-memory"))
|
||||
return cache
|
||||
|
||||
|
|
@ -2068,7 +2071,11 @@ async def test_refresh_of_a_rotated_token_answers_503_before_minting_while_redis
|
|||
(await _redeem_native(await _native_code(client_id, cache=issued), client_id, _Minter(), cache=issued)).body
|
||||
)
|
||||
rotated = json.loads(
|
||||
(await _refresh_native(payload["refresh_token"], client_id, _Minter(), _redis_that(AsyncMock(return_value=1)))).body
|
||||
(
|
||||
await _refresh_native(
|
||||
payload["refresh_token"], client_id, _Minter(), _redis_that(AsyncMock(return_value=1))
|
||||
)
|
||||
).body
|
||||
)["refresh_token"]
|
||||
|
||||
minter = _Minter()
|
||||
|
|
@ -2578,3 +2585,287 @@ async def test_token_exchange_relays_a_mint_refusal(failure, status, error):
|
|||
response = await _exchange_native(client_id, _Minter(failure), _Exchanger())
|
||||
assert response.status_code == status
|
||||
assert json.loads(response.body)["error"] == error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_flow_retains_step_up_scope_until_the_vendor_grants_it():
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
client_id = (await _register([REDIRECT_URI]))["client_id"]
|
||||
server = _scoped_mcp_server()
|
||||
with patch(_MANAGER_PATCH) as manager:
|
||||
manager.get_mcp_server_by_name.return_value = server
|
||||
response = _scoped_authorize(client_id, SCOPED_RESOURCE, scope="tools.write")
|
||||
vendor = AsyncMock(return_value="absent")
|
||||
described = await _describe_page(response, scoped_server=server, vendor=vendor)
|
||||
assert json.loads(described.body)["requested_scopes"] == ["tools.write"]
|
||||
assert json.loads(described.body)["connected"] is False
|
||||
vendor.assert_awaited_once_with("u1", server.server_id, ("tools.write",))
|
||||
premature = await _complete_page(response, scoped_server=server, vendor=vendor)
|
||||
assert premature.status_code == 400
|
||||
vendor.return_value = "present"
|
||||
complete = await _complete_page(response, scoped_server=server, vendor=vendor)
|
||||
assert complete.status_code == 303
|
||||
assert parse_qs(urlparse(complete.headers["location"]).query)["code"][0].startswith(GATEWAY_AUTH_CODE_PREFIX)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aggregate_step_up_requires_the_upstream_grant_without_narrowing_gateway_access():
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
client_id = (await _register([REDIRECT_URI]))["client_id"]
|
||||
server = _scoped_mcp_server()
|
||||
encoded = f"litellm:mcp_scope:{server.server_id.encode().hex()}:{b'tools.write'.hex()}"
|
||||
response = _scoped_authorize(client_id, "https://llm.example.com/mcp", scope=encoded)
|
||||
vendor = AsyncMock(return_value="absent")
|
||||
described = await _describe_page(response, scoped_server=server, vendor=vendor)
|
||||
assert json.loads(described.body)["connected"] is False
|
||||
assert json.loads(described.body)["requested_scopes"] == ["tools.write"]
|
||||
premature = await _complete_page(response, scoped_server=server, vendor=vendor)
|
||||
assert premature.status_code == 400
|
||||
vendor.return_value = "present"
|
||||
ready = await _describe_page(response, scoped_server=server, vendor=vendor)
|
||||
assert json.loads(ready.body)["state"] == "unscoped"
|
||||
assert json.loads(ready.body)["server_id"] is None
|
||||
completed = await _complete_page(response, scoped_server=server, vendor=vendor)
|
||||
code = parse_qs(urlparse(completed.headers["location"]).query)["code"][0]
|
||||
tokens = await _redeem(code, client_id)
|
||||
assert tokens.status_code == 200
|
||||
assert _opened_principal(json.loads(tokens.body)).resource_server_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"scope",
|
||||
[
|
||||
"litellm:mcp_scope:",
|
||||
"litellm:mcp_scope:aa:bb",
|
||||
"litellm:mcp_scope:6769746875622d6964:20",
|
||||
"litellm:mcp_scope:6964:5A",
|
||||
],
|
||||
)
|
||||
async def test_aggregate_step_up_rejects_malformed_permissions(scope):
|
||||
client_id = (await _register([REDIRECT_URI]))["client_id"]
|
||||
response = _scoped_authorize(client_id, "https://llm.example.com/mcp", scope=scope)
|
||||
assert response.status_code == 400
|
||||
assert json.loads(response.body)["error"] == "invalid_scope"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step_up_validates_every_target_before_credential_reads_and_preserves_each_grant():
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import encode_upstream_scope
|
||||
|
||||
client_id = (await _register([REDIRECT_URI]))["client_id"]
|
||||
first, second = _scoped_mcp_server("first"), _scoped_mcp_server("second")
|
||||
scopes = " ".join(
|
||||
[encode_upstream_scope(first.server_id, "first.write"), encode_upstream_scope(second.server_id, "second.write")]
|
||||
)
|
||||
response = _scoped_authorize(client_id, "https://llm.example.com", scope=scopes)
|
||||
handle, cookies = _flow_cookie_from(response)
|
||||
vendor = AsyncMock(return_value="absent")
|
||||
reachable = AsyncMock(side_effect=[True, False])
|
||||
with patch(_MANAGER_PATCH) as manager:
|
||||
manager.get_mcp_server_by_id.side_effect = lambda identity: {
|
||||
first.server_id: first,
|
||||
second.server_id: second,
|
||||
}.get(identity)
|
||||
refused = await describe_connect_flow(_request(cookies=cookies), handle, "u1", vendor, reachable)
|
||||
assert refused.status_code == 400
|
||||
vendor.assert_not_awaited()
|
||||
reachable.side_effect = None
|
||||
reachable.return_value = True
|
||||
vendor.side_effect = ["present", "absent"]
|
||||
pending = await describe_connect_flow(_request(cookies=cookies), handle, "u1", vendor, reachable)
|
||||
assert json.loads(pending.body)["server_id"] == second.server_id
|
||||
assert json.loads(pending.body)["requested_scopes"] == ["second.write"]
|
||||
assert [call.args for call in vendor.await_args_list] == [
|
||||
("u1", first.server_id, ("first.write",)),
|
||||
("u1", second.server_id, ("second.write",)),
|
||||
]
|
||||
vendor.reset_mock()
|
||||
wrong_user = await describe_connect_flow(_request(cookies=cookies), handle, "u2", vendor, reachable)
|
||||
assert wrong_user.status_code == 403
|
||||
vendor.assert_not_awaited()
|
||||
vendor.side_effect = None
|
||||
vendor.return_value = "present"
|
||||
complete = await complete_connect_flow(
|
||||
_request(cookies=cookies, method="POST"),
|
||||
handle,
|
||||
"u1",
|
||||
DualCache(),
|
||||
lookup_vendor_credential=vendor,
|
||||
lookup_server_reachability=reachable,
|
||||
)
|
||||
assert complete.status_code == 303
|
||||
assert vendor.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_named_resource_rejects_another_upstreams_permission_before_consent():
|
||||
from unittest.mock import patch
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import encode_upstream_scope
|
||||
|
||||
client_id = (await _register([REDIRECT_URI]))["client_id"]
|
||||
with patch(_MANAGER_PATCH) as manager:
|
||||
manager.get_mcp_server_by_name.return_value = _scoped_mcp_server()
|
||||
response = _scoped_authorize(client_id, SCOPED_RESOURCE, scope=encode_upstream_scope("other-id", "write"))
|
||||
assert response.status_code == 400
|
||||
assert json.loads(response.body)["error"] == "invalid_scope"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"identity, permission",
|
||||
[
|
||||
("server:id/one", "tools.write"),
|
||||
("snowman-☃", "https://permissions.example/read"),
|
||||
("id", "!#$%&'()*+,-./:;<=>?@[]^_`{|}~"),
|
||||
],
|
||||
)
|
||||
def test_upstream_permission_scope_round_trip_preserves_identity_and_permission(identity, permission):
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import decode_upstream_scope, encode_upstream_scope
|
||||
|
||||
encoded = encode_upstream_scope(identity, permission)
|
||||
decoded = decode_upstream_scope(encoded)
|
||||
assert decoded is not None
|
||||
assert (decoded.server_id, decoded.permission) == (identity, permission)
|
||||
assert " " not in encoded
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("state, expected", [("absent", 400), ("unavailable", 503)])
|
||||
async def test_upstream_step_up_cannot_finish_with_an_unproven_grant(state, expected):
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import encode_upstream_scope
|
||||
|
||||
client_id = (await _register([REDIRECT_URI]))["client_id"]
|
||||
server = _scoped_mcp_server()
|
||||
response = _scoped_authorize(
|
||||
client_id, "https://llm.example.com/mcp", scope=encode_upstream_scope(server.server_id, "tools.write")
|
||||
)
|
||||
vendor = AsyncMock(return_value=state)
|
||||
completed = await _complete_page(response, scoped_server=server, vendor=vendor)
|
||||
assert completed.status_code == expected
|
||||
assert "location" not in completed.headers
|
||||
vendor.return_value = "present"
|
||||
retried = await _complete_page(response, scoped_server=server, vendor=vendor)
|
||||
assert retried.status_code == 303
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("route", ["/mcp", "/toolset/analysis/mcp"])
|
||||
async def test_sdk_completes_aggregate_step_up_discovery_consent_and_pkce(route):
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx2
|
||||
from mcp.client.auth import OAuthClientProvider
|
||||
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientMetadata
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
_build_aggregate_authorization_server_response,
|
||||
oauth_protected_resource_root,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.exceptions import MCPUpstreamAuthError
|
||||
|
||||
server = _scoped_mcp_server()
|
||||
storage = AsyncMock()
|
||||
storage.get_tokens.return_value = None
|
||||
storage.get_client_info.return_value = None
|
||||
callback = AsyncMock()
|
||||
vendor = AsyncMock(return_value="absent")
|
||||
cache = DualCache()
|
||||
|
||||
async def consent(url):
|
||||
params = {key: values[0] for key, values in parse_qs(urlparse(url).query).items()}
|
||||
response = aggregate_authorize(request=_request(), session_user_id="u1", **params)
|
||||
described = await _describe_page(response, scoped_server=server, vendor=vendor)
|
||||
assert json.loads(described.body)["requested_scopes"] == ["tools.write"]
|
||||
assert json.loads(described.body)["connected"] is False
|
||||
premature = await _complete_page(response, scoped_server=server, vendor=vendor, cache=cache)
|
||||
assert premature.status_code == 400
|
||||
vendor.return_value = "present"
|
||||
completed = await _complete_page(response, scoped_server=server, vendor=vendor, cache=cache)
|
||||
assert completed.status_code == 303
|
||||
returned = parse_qs(urlparse(completed.headers["location"]).query)
|
||||
callback.return_value = AuthorizationCodeResult(code=returned["code"][0], state=returned["state"][0])
|
||||
|
||||
provider = OAuthClientProvider(
|
||||
server_url=f"https://llm.example.com{route}",
|
||||
client_metadata=OAuthClientMetadata(redirect_uris=[REDIRECT_URI], token_endpoint_auth_method="none"),
|
||||
storage=storage,
|
||||
redirect_handler=consent,
|
||||
callback_handler=callback,
|
||||
)
|
||||
flow = provider.async_auth_flow(httpx2.Request("POST", f"https://llm.example.com{route}"))
|
||||
initial = await anext(flow)
|
||||
challenge = MCPUpstreamAuthError(
|
||||
status_code=403,
|
||||
www_authenticate=None,
|
||||
server_name=server.server_name,
|
||||
server_id=server.server_id,
|
||||
required_scope="tools.write",
|
||||
).to_http_exception(base_url="https://llm.example.com", request_path=route)
|
||||
request = await flow.asend(httpx2.Response(challenge.status_code, headers=challenge.headers, request=initial))
|
||||
assert request.url.path == "/.well-known/oauth-protected-resource"
|
||||
request = await flow.asend(httpx2.Response(200, json=oauth_protected_resource_root(_request()), request=request))
|
||||
assert request.url.path == "/.well-known/oauth-authorization-server/mcp"
|
||||
request = await flow.asend(
|
||||
httpx2.Response(200, json=_build_aggregate_authorization_server_response(_request(), False), request=request)
|
||||
)
|
||||
assert request.url.path == "/register"
|
||||
registered = await register_aggregate_client(
|
||||
_request("/register", method="POST"), json.loads(request.content), token_exchange_available=False
|
||||
)
|
||||
request = await flow.asend(httpx2.Response(registered.status_code, content=registered.body, request=request))
|
||||
assert request.url.path == "/token"
|
||||
params = {key: values[0] for key, values in parse_qs(request.content.decode()).items()}
|
||||
tokens = await _redeem(cache=cache, **params)
|
||||
assert tokens.status_code == 200
|
||||
payload = json.loads(tokens.body)
|
||||
assert _opened_principal(payload).resource_server_id is None
|
||||
retry = await flow.asend(httpx2.Response(200, json=payload, request=request))
|
||||
assert retry.url == initial.url
|
||||
assert retry.headers["authorization"] == f"Bearer {payload['access_token']}"
|
||||
with pytest.raises(StopAsyncIteration):
|
||||
await flow.asend(httpx2.Response(200, json={"result": "write completed"}, request=retry))
|
||||
callback.assert_awaited_once()
|
||||
storage.set_tokens.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scope",
|
||||
["tools.write", "litellm:mcp_scope::7772697465", "litellm:mcp_scope:6964:5A", "litellm:mcp_scope:6964:7772 697465"],
|
||||
)
|
||||
def test_upstream_permission_rejects_noncanonical_tokens(scope):
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import decode_upstream_scope
|
||||
|
||||
assert decode_upstream_scope(scope) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_named_step_up_finish_describes_and_retains_its_single_server_ceiling():
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import encode_upstream_scope
|
||||
|
||||
client_id = (await _register([REDIRECT_URI]))["client_id"]
|
||||
server = _scoped_mcp_server()
|
||||
with patch(_MANAGER_PATCH) as manager:
|
||||
manager.get_mcp_server_by_name.return_value = server
|
||||
response = _scoped_authorize(
|
||||
client_id, SCOPED_RESOURCE, scope=encode_upstream_scope(server.server_id, "tools.write")
|
||||
)
|
||||
vendor = AsyncMock(return_value="present")
|
||||
ready = await _describe_page(response, scoped_server=server, vendor=vendor)
|
||||
payload = json.loads(ready.body)
|
||||
assert payload["state"] == "interactive"
|
||||
assert payload["server_id"] == server.server_id
|
||||
assert payload["connected"] is True
|
||||
completed = await _complete_page(response, scoped_server=server, vendor=vendor)
|
||||
code = parse_qs(urlparse(completed.headers["location"]).query)["code"][0]
|
||||
tokens = await _redeem(code, client_id)
|
||||
assert tokens.status_code == 200
|
||||
assert _opened_principal(json.loads(tokens.body)).resource_server_id == server.server_id
|
||||
|
|
|
|||
|
|
@ -166,7 +166,11 @@ async def test_persistent_tool_call_with_limit_one_completes_without_nested_admi
|
|||
oauth2_headers: Final = (
|
||||
{"Authorization": "Bearer subject-token"} if auth_type == MCPAuth.oauth2_token_exchange else None
|
||||
)
|
||||
context_token: Final = active_mcp_request_ctx_var.set(cast(ServerRequestContext, object()))
|
||||
context_token: Final = active_mcp_request_ctx_var.set(
|
||||
ServerRequestContext(
|
||||
session=AsyncMock(), lifespan_context={}, protocol_version="2025-11-25", method="tools/call", request=None
|
||||
)
|
||||
)
|
||||
manager.track_gateway_session(gateway_session_id)
|
||||
try:
|
||||
with patch.object(manager, "create_mcp_client", new=AsyncMock(return_value=client)):
|
||||
|
|
@ -209,7 +213,11 @@ async def test_persistent_session_admission_uses_updated_server_limit() -> None:
|
|||
submitted=second_submitted,
|
||||
release=release_second,
|
||||
)
|
||||
context_token: Final = active_mcp_request_ctx_var.set(cast(ServerRequestContext, object()))
|
||||
context_token: Final = active_mcp_request_ctx_var.set(
|
||||
ServerRequestContext(
|
||||
session=AsyncMock(), lifespan_context={}, protocol_version="2025-11-25", method="tools/call", request=None
|
||||
)
|
||||
)
|
||||
manager.track_gateway_session(gateway_session_id)
|
||||
|
||||
async def dispatch() -> CallToolResult:
|
||||
|
|
@ -275,7 +283,11 @@ async def test_queued_persistent_call_survives_header_change_while_waiting_for_a
|
|||
second_client: Final = _AdmissionProbeClient(
|
||||
server.server_id, "second-fingerprint", tracker, submitted=second_submitted
|
||||
)
|
||||
context_token: Final = active_mcp_request_ctx_var.set(cast(ServerRequestContext, object()))
|
||||
context_token: Final = active_mcp_request_ctx_var.set(
|
||||
ServerRequestContext(
|
||||
session=AsyncMock(), lifespan_context={}, protocol_version="2025-11-25", method="tools/call", request=None
|
||||
)
|
||||
)
|
||||
manager.track_gateway_session(gateway_session_id)
|
||||
|
||||
async def hold_slot() -> None:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
from litellm.proxy._experimental.mcp_server import operations as mcp_operations
|
||||
|
||||
"""Unit tests for MCP OAuth passthrough tool-fetch behavior."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
|
@ -462,7 +464,6 @@ async def test_fetch_tools_logs_upstream_request_details_on_500(caplog):
|
|||
assert "upstream-token-0123456789" not in caplog.text
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_creation_failure_logs_sanitized_exchange(monkeypatch, caplog):
|
||||
manager = MCPServerManager()
|
||||
|
|
@ -498,3 +499,243 @@ def test_passthrough_admission_recognizes_only_matching_authorization(oauth_head
|
|||
|
||||
server = MCPServer(server_id="catalog", name="catalog", alias="catalog", transport=MCPTransport.http)
|
||||
assert _client_has_passthrough_authorization(server, oauth_headers, server_headers) is authorized
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base_url, request_path, scope, expected", [
|
||||
("https://gateway.test/proxy", "/alias/mcp", "tools.write", 'Bearer resource_metadata="https://gateway.test/proxy/.well-known/oauth-protected-resource/alias/mcp", error="insufficient_scope", scope="tools.write"'),
|
||||
("https://gateway.test/proxy", "/mcp/alias", "", 'Bearer resource_metadata="https://gateway.test/proxy/.well-known/oauth-protected-resource/mcp/alias", error="insufficient_scope"'),
|
||||
(None, None, "tools.write", 'Bearer error="insufficient_scope", scope="tools.write"'),
|
||||
])
|
||||
def test_managed_step_up_challenge_uses_only_the_gateway_metadata(base_url, request_path, scope, expected):
|
||||
error = MCPUpstreamAuthError(status_code=403, www_authenticate=None, server_name="alias", required_scope=scope)
|
||||
response = error.to_http_exception(base_url=base_url, request_path=request_path)
|
||||
assert response.status_code == 403
|
||||
assert response.headers == {"www-authenticate": expected}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scope_challenge_replaces_pending_stream_headers_once():
|
||||
from litellm.proxy._experimental.mcp_server.scope_challenge import OAuthScopeResponse
|
||||
|
||||
send = AsyncMock()
|
||||
response = OAuthScopeResponse(send, "https://gateway.example", "/docs/mcp")
|
||||
await response({"type": "http.response.start", "status": 200, "headers": []})
|
||||
body = asyncio.create_task(response({"type": "http.response.body", "body": b"event: message\n"}))
|
||||
await asyncio.sleep(0)
|
||||
send.assert_not_awaited()
|
||||
response.deny(MCPUpstreamAuthError(403, None, "docs", required_scope="tools.write"))
|
||||
await asyncio.wait_for(body, 1)
|
||||
await response({"type": "http.response.body", "body": b"private tool error"})
|
||||
assert send.await_count == 2
|
||||
start, result = [call.args[0] for call in send.await_args_list]
|
||||
assert start["status"] == 403
|
||||
challenge = dict(start["headers"])[b"www-authenticate"].decode()
|
||||
assert challenge == ('Bearer resource_metadata="https://gateway.example/.well-known/oauth-protected-resource/docs/mcp", '
|
||||
'error="insufficient_scope", scope="tools.write"')
|
||||
assert result["body"] == b'{"detail":"Forbidden"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scope_authorization_releases_progress_without_waiting_for_tool_result():
|
||||
import httpx2
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from starlette.requests import Request
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.mcp_context import active_mcp_request_ctx_var
|
||||
from litellm.proxy._experimental.mcp_server.scope_challenge import (
|
||||
SCOPE_RESPONSE_KEY,
|
||||
OAuthScopeResponse,
|
||||
)
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.legacy_callbacks import get_scope_response, record_upstream_tool_authorization
|
||||
|
||||
send = AsyncMock()
|
||||
gate = OAuthScopeResponse(send, "https://gateway.example", "/mcp/docs")
|
||||
request = Request({"type": "http", SCOPE_RESPONSE_KEY: gate})
|
||||
context = ServerRequestContext(session=MagicMock(), lifespan_context={}, protocol_version="2025-11-25", method="tools/call", request=request)
|
||||
token = active_mcp_request_ctx_var.set(context)
|
||||
try:
|
||||
await gate({"type": "http.response.start", "status": 200, "headers": []})
|
||||
progress = asyncio.create_task(gate({"type": "http.response.body", "body": b"progress", "more_body": True}))
|
||||
await asyncio.sleep(0)
|
||||
for method, status in [("initialize", 200), ("tools/list", 200), ("tools/call", 403)]:
|
||||
await record_upstream_tool_authorization(httpx2.Response(status, request=httpx2.Request("POST", "https://upstream.example/mcp", json={"method": method})), pending=get_scope_response())
|
||||
assert gate.pending
|
||||
unread = httpx2.Request("POST", "https://upstream.example/mcp", content=iter([b'{"method":"tools/call"}']))
|
||||
await record_upstream_tool_authorization(httpx2.Response(200, request=unread), pending=get_scope_response())
|
||||
assert gate.pending
|
||||
# Use the actual HTTP response hook contract, before reading the tool response body.
|
||||
from litellm.experimental_mcp_client.client import MCPClient
|
||||
|
||||
upstream = MCPClient(server_url="https://upstream.example/mcp")
|
||||
async with upstream._create_httpx_client_factory(transport=httpx2.MockTransport(lambda request: httpx2.Response(200, content=b"tool result")))() as client:
|
||||
async with client.stream("POST", "https://upstream.example/mcp", json={"method": "tools/call"}):
|
||||
await asyncio.wait_for(progress, 1)
|
||||
assert send.await_args_list[-1].args[0]["body"] == b"progress"
|
||||
gate.deny(MCPUpstreamAuthError(403, None, "docs", required_scope="tools.write"))
|
||||
await gate({"type": "http.response.body", "body": b"result"})
|
||||
assert send.await_args_list[0].args[0]["status"] == 200
|
||||
assert send.await_args_list[-1].args[0]["body"] == b"result"
|
||||
finally:
|
||||
active_mcp_request_ctx_var.reset(token)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("failure", [ValueError, asyncio.CancelledError])
|
||||
async def test_scope_middleware_releases_waiting_response_on_validation_or_cancellation(failure):
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from starlette.requests import Request
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.scope_challenge import SCOPE_RESPONSE_KEY, OAuthScopeResponse, finish_scope_response
|
||||
|
||||
send = AsyncMock()
|
||||
gate = OAuthScopeResponse(send, "https://gateway.example", "/mcp/docs")
|
||||
context = ServerRequestContext(session=MagicMock(), lifespan_context={}, protocol_version="2025-11-25", method="tools/call", request=Request({"type": "http", SCOPE_RESPONSE_KEY: gate}))
|
||||
with pytest.raises(failure):
|
||||
await finish_scope_response(context, AsyncMock(side_effect=failure))
|
||||
await gate({"type": "http.response.start", "status": 200, "headers": []})
|
||||
await gate({"type": "http.response.body", "body": b"error"})
|
||||
assert not gate.pending
|
||||
assert send.await_args_list[-1].args[0]["body"] == b"error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scope_response_preserves_transport_validation_errors():
|
||||
from litellm.proxy._experimental.mcp_server.scope_challenge import OAuthScopeResponse
|
||||
|
||||
send = AsyncMock()
|
||||
gate = OAuthScopeResponse(send, "https://gateway.example", "/mcp/docs")
|
||||
await gate({"type": "http.response.start", "status": 400, "headers": []})
|
||||
await gate({"type": "http.response.body", "body": b"invalid request"})
|
||||
assert send.await_args_list[0].args[0]["status"] == 400
|
||||
assert send.await_args_list[1].args[0]["body"] == b"invalid request"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("denied", [False, True])
|
||||
@pytest.mark.parametrize("stateless", [False, True])
|
||||
@pytest.mark.parametrize("padding_size", [0, 131072])
|
||||
async def test_streamable_sdk_relays_scope_challenge_before_committing_http_response(denied, stateless, padding_size):
|
||||
import httpx2
|
||||
from mcp.server.lowlevel import Server
|
||||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||
from mcp.types import CallToolResult, TextContent
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.legacy_callbacks import get_scope_response
|
||||
from litellm.proxy._experimental.mcp_server.mcp_context import active_mcp_request_ctx_var
|
||||
from litellm.proxy._experimental.mcp_server.scope_challenge import SCOPE_RESPONSE_KEY, OAuthScopeResponse, finish_scope_response
|
||||
|
||||
async def call_tool(context, params):
|
||||
token = active_mcp_request_ctx_var.set(context)
|
||||
try:
|
||||
gate = get_scope_response()
|
||||
assert gate is not None
|
||||
if denied:
|
||||
gate.deny(MCPUpstreamAuthError(403, None, "docs", required_scope="tools.write"))
|
||||
return CallToolResult(content=[TextContent(type="text", text="done")])
|
||||
finally:
|
||||
active_mcp_request_ctx_var.reset(token)
|
||||
|
||||
server = Server("scope-test", on_call_tool=call_tool)
|
||||
server.middleware.append(finish_scope_response)
|
||||
manager = StreamableHTTPSessionManager(server, stateless=stateless)
|
||||
|
||||
async def app(scope, receive, send):
|
||||
response = OAuthScopeResponse(send, "https://gateway.example", "/mcp/docs")
|
||||
scope[SCOPE_RESPONSE_KEY] = response
|
||||
await manager.handle_request(scope, receive, response)
|
||||
|
||||
async with manager.run():
|
||||
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app)) as client:
|
||||
headers = {"Accept": "application/json, text/event-stream", "MCP-Protocol-Version": "2025-11-25"}
|
||||
if not stateless:
|
||||
initialized = await asyncio.wait_for(client.post("https://gateway.example/mcp/docs", headers=headers, json={
|
||||
"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {
|
||||
"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "scope-test" + "x" * padding_size, "version": "1"},
|
||||
},
|
||||
}), 3)
|
||||
assert initialized.status_code == 200
|
||||
headers["MCP-Session-Id"] = initialized.headers["mcp-session-id"]
|
||||
notified = await client.post("https://gateway.example/mcp/docs", headers=headers, json={"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||
assert notified.status_code == 202
|
||||
response = await asyncio.wait_for(client.post("https://gateway.example/mcp/docs", headers=headers,
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "write", "arguments": {"padding": "x" * padding_size}}}), 3)
|
||||
assert response.status_code == (403 if denied else 200)
|
||||
if denied:
|
||||
assert 'error="insufficient_scope"' in response.headers["www-authenticate"]
|
||||
assert response.json() == {"detail": "Forbidden"}
|
||||
else:
|
||||
assert '"text":"done"' in response.text
|
||||
assert "www-authenticate" not in response.headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scope_challenges_are_isolated_between_concurrent_requests():
|
||||
import httpx2
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from starlette.requests import Request
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.legacy_callbacks import get_scope_response, record_upstream_tool_authorization
|
||||
from litellm.proxy._experimental.mcp_server.mcp_context import active_mcp_request_ctx_var
|
||||
from litellm.proxy._experimental.mcp_server.scope_challenge import SCOPE_RESPONSE_KEY, OAuthScopeResponse
|
||||
|
||||
async def request_scope(denied):
|
||||
send = AsyncMock()
|
||||
gate = OAuthScopeResponse(send, "https://gateway.example", "/mcp/docs")
|
||||
context = ServerRequestContext(session=MagicMock(), lifespan_context={}, protocol_version="2025-11-25", method="tools/call", request=Request({"type": "http", SCOPE_RESPONSE_KEY: gate}))
|
||||
token = active_mcp_request_ctx_var.set(context)
|
||||
try:
|
||||
await gate({"type": "http.response.start", "status": 200, "headers": []})
|
||||
await asyncio.sleep(0)
|
||||
assert get_scope_response() is gate
|
||||
if denied:
|
||||
gate.deny(MCPUpstreamAuthError(403, None, "docs", required_scope="tools.write"))
|
||||
else:
|
||||
await record_upstream_tool_authorization(httpx2.Response(200, request=httpx2.Request("POST", "https://upstream.example/mcp", json={"method": "tools/call"})), pending=get_scope_response())
|
||||
await gate({"type": "http.response.body", "body": b"done"})
|
||||
return send.await_args_list[0].args[0]["status"]
|
||||
finally:
|
||||
active_mcp_request_ctx_var.reset(token)
|
||||
|
||||
assert await asyncio.gather(request_scope(True), request_scope(False)) == [403, 200]
|
||||
assert get_scope_response() is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", [b"", b"{", b"[]", b'{"method":"initialize"}'])
|
||||
def test_non_tool_payloads_do_not_hold_response_headers(body):
|
||||
from litellm.proxy._experimental.mcp_server.scope_challenge import is_tool_call_request, scope_response_from_request
|
||||
|
||||
assert not is_tool_call_request(body)
|
||||
assert scope_response_from_request(None) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scope_middleware_preserves_non_http_requests():
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.types import CallToolResult, TextContent
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.scope_challenge import finish_scope_response
|
||||
|
||||
context = ServerRequestContext(session=MagicMock(), lifespan_context={}, protocol_version="2025-11-25", method="tools/call")
|
||||
async def call_next(request):
|
||||
return CallToolResult(content=[TextContent(type="text", text=request.method)])
|
||||
|
||||
returned = await finish_scope_response(context, call_next)
|
||||
assert returned.content[0].text == "tools/call"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/mcp", "/toolset/demo/mcp"])
|
||||
def test_managed_step_up_metadata_matches_the_sdk_configured_resource(path):
|
||||
from mcp.shared.auth_utils import check_resource_allowed
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
error = MCPUpstreamAuthError(403, None, "docs", required_scope="tools.write")
|
||||
response = error.to_http_exception("https://gateway.test", path)
|
||||
challenge = response.headers["www-authenticate"]
|
||||
metadata = challenge.split('resource_metadata="', 1)[1].split('"', 1)[0]
|
||||
metadata_path = urlsplit(metadata).path
|
||||
resource_path = metadata_path.removeprefix("/.well-known/oauth-protected-resource")
|
||||
assert check_resource_allowed(
|
||||
requested_resource=f"https://gateway.test{path}",
|
||||
configured_resource=f"https://gateway.test{resource_path}",
|
||||
), "the SDK must accept the advertised resource before it can reauthorize"
|
||||
|
|
|
|||
|
|
@ -2211,6 +2211,31 @@ class TestMCPServerManager:
|
|||
assert exc_info.value.www_authenticate == challenge
|
||||
assert mock_client.call_tool.call_args.kwargs.get("raise_on_error") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_managed_scope_challenge_requires_reauthorization_without_retry(self):
|
||||
server = MCPServer(
|
||||
server_id="managed-scope", name="managed-scope", alias="managed-scope",
|
||||
url="https://up.example.com/mcp", transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2, oauth2_flow="authorization_code",
|
||||
)
|
||||
upstream_error = self._upstream_status_error(
|
||||
403, 'Bearer error="insufficient_scope", scope="tools.write", resource_metadata="https://untrusted.test/metadata"'
|
||||
)
|
||||
manager = MCPServerManager()
|
||||
client = AsyncMock()
|
||||
client.call_tool = AsyncMock(side_effect=upstream_error)
|
||||
manager.create_mcp_client = AsyncMock(return_value=client)
|
||||
|
||||
with pytest.raises(MCPUpstreamAuthError) as raised:
|
||||
await self._run_call_regular(manager, server)
|
||||
|
||||
response = raised.value.to_http_exception("https://gateway.test", "/managed-scope/mcp")
|
||||
assert response.status_code == 403
|
||||
assert response.headers == {
|
||||
"www-authenticate": 'Bearer resource_metadata="https://gateway.test/.well-known/oauth-protected-resource/managed-scope/mcp", error="insufficient_scope", scope="tools.write"'
|
||||
}
|
||||
assert client.call_tool.await_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("is_error", [False, True])
|
||||
async def test_call_passthrough_returns_tool_result_unchanged(self, is_error):
|
||||
|
|
@ -4533,6 +4558,7 @@ class TestMCPServerManager:
|
|||
f"{issuer}/.well-known/oauth-authorization-server",
|
||||
{
|
||||
"issuer": issuer,
|
||||
"response_types_supported": ["code"],
|
||||
"authorization_endpoint": "https://idp.example.com/authorize",
|
||||
"token_endpoint": "https://idp.example.com/token",
|
||||
"scopes_supported": ["read", "write"],
|
||||
|
|
@ -4554,6 +4580,11 @@ class TestMCPServerManager:
|
|||
|
||||
assert result.client_id_metadata_document_supported is True
|
||||
|
||||
assert result.authorization_server_metadata is not None
|
||||
assert result.authorization_server_metadata["scopes_supported"] == ["read", "write"]
|
||||
merged = manager._merge_discovered_oauth_metadata(self._passthrough_call_server(MCPAuth.oauth2), result)
|
||||
assert merged.authorization_server_metadata == result.authorization_server_metadata
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_single_authorization_server_metadata_rejects_issuer_mismatch(self):
|
||||
"""RFC 8414 §3.3 fail-closed: a document self-attesting a DIFFERENT issuer than the one it was
|
||||
|
|
@ -7255,6 +7286,47 @@ class TestMCPServerManager:
|
|||
def _unrestricted_auth() -> UserAPIKeyAuth:
|
||||
return UserAPIKeyAuth(api_key="sk-test")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("managed", [False, True])
|
||||
async def test_tool_response_waits_only_for_managed_user_oauth_authorization(self, managed):
|
||||
import asyncio
|
||||
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from starlette.requests import Request
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.mcp_context import active_mcp_request_ctx_var
|
||||
from litellm.proxy._experimental.mcp_server.scope_challenge import SCOPE_RESPONSE_KEY, OAuthScopeResponse
|
||||
|
||||
auth = self._unrestricted_auth()
|
||||
manager, logging = self._manager_ready_for_call_tool(
|
||||
[MCPTool(name="test_tool", inputSchema={"type": "object"})],
|
||||
caller=ListedToolsCaller(user_api_key_auth=auth),
|
||||
)
|
||||
if managed:
|
||||
manager.registry["test-server"] = manager.registry["test-server"].model_copy(update={
|
||||
"auth_type": MCPAuth.oauth2, "oauth2_flow": "authorization_code",
|
||||
})
|
||||
send = AsyncMock()
|
||||
gate = OAuthScopeResponse(send, "https://gateway.example", "/mcp/test-server")
|
||||
context = ServerRequestContext(session=MagicMock(), lifespan_context={}, protocol_version="2025-11-25", method="tools/call", request=Request({"type": "http", SCOPE_RESPONSE_KEY: gate}))
|
||||
token = active_mcp_request_ctx_var.set(context)
|
||||
await gate({"type": "http.response.start", "status": 200, "headers": []})
|
||||
progress = asyncio.create_task(gate({"type": "http.response.body", "body": b"progress", "more_body": True}))
|
||||
|
||||
async def upstream_call(*args, **kwargs):
|
||||
await asyncio.sleep(0)
|
||||
assert send.await_count == (0 if managed else 2)
|
||||
return CallToolResult(content=[], is_error=False)
|
||||
|
||||
manager.create_mcp_client.return_value.call_tool.side_effect = upstream_call
|
||||
try:
|
||||
result = await manager.call_tool(server_name="test-server", name="test_tool", arguments={}, user_api_key_auth=auth, proxy_logging_obj=logging)
|
||||
assert not result.is_error
|
||||
finally:
|
||||
gate.allow()
|
||||
await asyncio.wait_for(progress, 1)
|
||||
active_mcp_request_ctx_var.reset(token)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_hands_listed_tool_description_and_schema_to_pre_call_hooks(self):
|
||||
schema = {"type": "object", "properties": {"param": {"type": "string"}}, "required": ["param"]}
|
||||
|
|
@ -18051,7 +18123,10 @@ async def test_unreusable_upstream_session_is_replaced_before_cleanup_finishes(
|
|||
|
||||
@pytest.fixture
|
||||
def _active_mcp_request_context() -> Iterator[None]:
|
||||
token: Final = active_mcp_request_ctx_var.set(cast(ServerRequestContext, object()))
|
||||
context: Final = ServerRequestContext(
|
||||
session=MagicMock(), lifespan_context={}, protocol_version="2025-11-25", method="tools/call", request=None
|
||||
)
|
||||
token: Final = active_mcp_request_ctx_var.set(context)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
|
|
@ -19973,17 +20048,23 @@ def test_oauth_rebuild_retains_only_corroborated_cimd_capability(capability: boo
|
|||
transport=MCPTransport.http, auth_type=MCPAuth.oauth2,
|
||||
authorization_url="https://idp.example.com/authorize", token_url="https://idp.example.com/token",
|
||||
client_id_metadata_document_supported=capability,
|
||||
authorization_server_metadata={"issuer": "https://idp.example.com", "response_types_supported": ["code"], "scopes_supported": ["offline_access"]},
|
||||
)
|
||||
rebuilt: Final = previous.model_copy(update={
|
||||
"client_id_metadata_document_supported": not capability if rebuild == "fresh_discovery" else None,
|
||||
"authorization_url": "https://changed.example.com/authorize" if rebuild == "repointed" else previous.authorization_url,
|
||||
"token_url": None,
|
||||
"authorization_server_metadata": {"scopes_supported": []} if rebuild == "fresh_discovery" else None,
|
||||
"issuer": "https://idp.example.com" if rebuild == "anchored" else None,
|
||||
"issuer_is_anchored": rebuild == "anchored",
|
||||
})
|
||||
carry_forward_resolved_oauth_endpoints(rebuilt, previous)
|
||||
expected: Final = not capability if rebuild == "fresh_discovery" else None if rebuild in ("repointed", "anchored") else capability
|
||||
assert rebuilt.client_id_metadata_document_supported is expected
|
||||
assert rebuilt.authorization_server_metadata == (
|
||||
{"scopes_supported": []} if rebuild == "fresh_discovery"
|
||||
else previous.authorization_server_metadata if rebuild == "same" else None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -264,11 +264,19 @@ async def test_mcp_server_tool_call_strips_custom_litellm_key_header(_mcp_reques
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_server_tool_call_relays_upstream_auth_error_as_iserror(_mcp_request_ctx):
|
||||
"""The MCP session manager serializes handler exceptions as JSON-RPC errors, so a mid-session
|
||||
tool call cannot emit a raw 401 the way the REST path does. mcp_server_tool_call must turn an
|
||||
upstream MCPUpstreamAuthError into an explicit isError result naming the status, not a masked
|
||||
500 or a raw traceback, so the client still learns it must re-authenticate upstream."""
|
||||
@pytest.mark.parametrize("required_scope, http_transport", [(None, False), (None, True), ("tools.write", True)])
|
||||
async def test_mcp_server_tool_call_relays_upstream_auth_error_as_iserror(
|
||||
_mcp_request_ctx, required_scope, http_transport
|
||||
):
|
||||
from starlette.requests import Request
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.scope_challenge import SCOPE_RESPONSE_KEY, OAuthScopeResponse
|
||||
|
||||
status_code = 403 if required_scope is not None else 401
|
||||
send = AsyncMock()
|
||||
response = OAuthScopeResponse(send, "https://gateway.example", "/mcp/pt")
|
||||
request = Request({"type": "http", "headers": [], SCOPE_RESPONSE_KEY: response})
|
||||
await response({"type": "http.response.start", "status": 200, "headers": []})
|
||||
try:
|
||||
from litellm.proxy._experimental.mcp_server.exceptions import MCPUpstreamAuthError
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
|
|
@ -284,7 +292,9 @@ async def test_mcp_server_tool_call_relays_upstream_auth_error_as_iserror(_mcp_r
|
|||
return data
|
||||
|
||||
async def mock_call_mcp_tool(*args, **kwargs):
|
||||
raise MCPUpstreamAuthError(status_code=401, www_authenticate="Bearer", server_name="pt")
|
||||
raise MCPUpstreamAuthError(
|
||||
status_code=status_code, www_authenticate=None, server_name="pt", required_scope=required_scope
|
||||
)
|
||||
|
||||
mock_logger = MagicMock()
|
||||
with patch(
|
||||
|
|
@ -298,20 +308,27 @@ async def test_mcp_server_tool_call_relays_upstream_auth_error_as_iserror(_mcp_r
|
|||
with patch("litellm.proxy.proxy_server.proxy_config", MagicMock()):
|
||||
with patch("litellm.proxy._experimental.mcp_server.operations.verbose_logger", mock_logger):
|
||||
result = await mcp_server_tool_call(
|
||||
_mcp_request_ctx(), _call_tool_params("test_tool", {"param": "value"})
|
||||
_mcp_request_ctx(request=request if http_transport else None),
|
||||
_call_tool_params("test_tool", {"param": "value"}),
|
||||
)
|
||||
|
||||
assert result.is_error is True
|
||||
# The dedicated MCPUpstreamAuthError branch (not the generic Exception fallthrough) produces this
|
||||
# specific message and logs at info, never a traceback via verbose_logger.exception.
|
||||
assert "upstream authentication required" in result.content[0].text
|
||||
assert "401" in result.content[0].text
|
||||
assert str(status_code) in result.content[0].text
|
||||
exception_calls = [str(c.args[0]) for c in mock_logger.exception.call_args_list if c.args]
|
||||
assert not any("mcp_server_tool_call" in m for m in exception_calls), (
|
||||
"must not log a traceback for the expected re-auth"
|
||||
)
|
||||
info_calls = [str(c.args[0]) for c in mock_logger.info.call_args_list if c.args]
|
||||
assert any("Upstream auth failure" in m for m in info_calls)
|
||||
response.allow()
|
||||
await response({"type": "http.response.body", "body": b"tool error"})
|
||||
start = send.await_args_list[0].args[0]
|
||||
assert start["status"] == (403 if required_scope is not None else 200)
|
||||
if required_scope is not None:
|
||||
assert b'error="insufficient_scope"' in dict(start["headers"])[b"www-authenticate"]
|
||||
|
||||
|
||||
def test_prepare_mcp_server_headers_case_insensitive_extra_headers():
|
||||
|
|
@ -2464,9 +2481,12 @@ async def test_mcp_routing_caps_body_peek_for_oversized_chunked_body():
|
|||
(("é", 0), ("é", 1), ("中", 1), ("中", 2), ("😀", 1), ("😀", 2), ("😀", 3)),
|
||||
)
|
||||
async def test_mcp_routing_peek_survives_multibyte_char_split_at_cap(
|
||||
method: str, chunked: bool, character: str, bytes_before_cap: int
|
||||
method: str, chunked: bool, character: str, bytes_before_cap: int, _mcp_request_ctx
|
||||
) -> None:
|
||||
from starlette.requests import Request
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import server as mcp_module
|
||||
from litellm.proxy._experimental.mcp_server.scope_challenge import finish_scope_response
|
||||
|
||||
params: Final = (
|
||||
{
|
||||
|
|
@ -2490,9 +2510,14 @@ async def test_mcp_routing_peek_survives_multibyte_char_split_at_cap(
|
|||
send: Final = AsyncMock()
|
||||
received: Final[asyncio.Future[bytes]] = asyncio.get_running_loop().create_future()
|
||||
|
||||
async def handle_request(_: Scope, downstream_receive: Receive, outgoing: Send) -> None:
|
||||
async def handle_request(incoming: Scope, downstream_receive: Receive, outgoing: Send) -> None:
|
||||
assert receive.await_count == (2 if chunked else 1)
|
||||
received.set_result(await _drain_body(downstream_receive))
|
||||
|
||||
async def dispatch(context):
|
||||
return CallToolResult(content=[])
|
||||
|
||||
await finish_scope_response(_mcp_request_ctx(request=Request(incoming)), dispatch)
|
||||
await outgoing({"type": "http.response.start", "status": 200, "headers": []})
|
||||
await outgoing({"type": "http.response.body", "body": b"{}"})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import { webcrypto } from "node:crypto";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import ConnectFlowBanner from "./ConnectFlowBanner";
|
||||
|
||||
const locationDescriptor = Object.getOwnPropertyDescriptor(window, "location")!;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
window.sessionStorage.clear();
|
||||
Object.defineProperty(window, "location", locationDescriptor);
|
||||
});
|
||||
|
||||
it("carries a challenged scope through the connect button into vendor authorization", async () => {
|
||||
vi.stubGlobal("crypto", webcrypto);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => Response.json({ client_id: "registered-client" })),
|
||||
);
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: {
|
||||
href: "https://gateway.example/ui/connect?connect_flow=flow-handle",
|
||||
origin: "https://gateway.example",
|
||||
pathname: "/ui/connect",
|
||||
},
|
||||
});
|
||||
render(
|
||||
<ConnectFlowBanner
|
||||
flowHandle="flow-handle"
|
||||
flow={{
|
||||
state: "interactive",
|
||||
client_origin: "https://client.example",
|
||||
server_id: "server-1",
|
||||
server_name: "Scoped tools",
|
||||
connected: false,
|
||||
requested_scopes: ["tools.write"],
|
||||
}}
|
||||
accessToken="caller"
|
||||
onConnected={vi.fn()}
|
||||
failed={false}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(window.location.href).toContain("scope=tools.write"));
|
||||
expect(new URL(window.location.href, window.location.origin).pathname).toBe(
|
||||
"/v1/mcp/server/oauth/server-1/authorize",
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /finish connecting/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("starts consent for the next upstream in the same connect flow", async () => {
|
||||
vi.stubGlobal("crypto", webcrypto);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => Response.json({ client_id: "registered-client" })),
|
||||
);
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: {
|
||||
href: "https://gateway.example/ui/connect?connect_flow=flow-handle",
|
||||
origin: "https://gateway.example",
|
||||
pathname: "/ui/connect",
|
||||
},
|
||||
});
|
||||
const props = {
|
||||
flowHandle: "flow-handle",
|
||||
accessToken: "caller",
|
||||
onConnected: vi.fn(),
|
||||
failed: false,
|
||||
};
|
||||
const first = {
|
||||
state: "interactive" as const,
|
||||
client_origin: "https://client.example",
|
||||
server_id: "first",
|
||||
server_name: "First",
|
||||
connected: false,
|
||||
requested_scopes: ["first.write"],
|
||||
};
|
||||
const { rerender } = render(<ConnectFlowBanner {...props} flow={first} />);
|
||||
await waitFor(() => expect(window.location.href).toContain("scope=first.write"));
|
||||
rerender(
|
||||
<ConnectFlowBanner
|
||||
{...props}
|
||||
flow={{ ...first, server_id: "second", server_name: "Second", requested_scopes: ["second.write"] }}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(window.location.href).toContain("scope=second.write"));
|
||||
expect(new URL(window.location.href, window.location.origin).pathname).toBe("/v1/mcp/server/oauth/second/authorize");
|
||||
expect(screen.queryByRole("button", { name: /finish connecting/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -76,11 +76,13 @@ const ConnectFlowBanner: React.FC<Props> = ({ flowHandle, flow, accessToken, onC
|
|||
<div className="flex shrink-0 gap-2">
|
||||
{vendorServer !== null && (
|
||||
<OAuth2ConnectButton
|
||||
key={vendorServer.server_id}
|
||||
server={vendorServer}
|
||||
accessToken={accessToken}
|
||||
onConnect={onConnected}
|
||||
scopes={flow?.requested_scopes}
|
||||
variant="button"
|
||||
autoStartKey={`litellm-mcp-autostart:${flowHandle}`}
|
||||
autoStartKey={`litellm-mcp-autostart:${flowHandle}:${vendorServer.server_id}`}
|
||||
/>
|
||||
)}
|
||||
<form method="POST" action={action}>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ interface OAuth2ConnectButtonProps {
|
|||
onConnect: (serverId: string) => void;
|
||||
variant?: "badge" | "button";
|
||||
autoStartKey?: string | null;
|
||||
scopes?: string[];
|
||||
}
|
||||
|
||||
export const OAuth2ConnectButton: React.FC<OAuth2ConnectButtonProps> = ({
|
||||
|
|
@ -39,14 +40,17 @@ export const OAuth2ConnectButton: React.FC<OAuth2ConnectButtonProps> = ({
|
|||
onConnect,
|
||||
variant = "badge",
|
||||
autoStartKey = null,
|
||||
scopes,
|
||||
}) => {
|
||||
const name = server.server_name ?? server.alias ?? server.server_id;
|
||||
const { startOAuthFlow, status } = useUserMcpOAuthFlow({
|
||||
const flowOptions = {
|
||||
accessToken,
|
||||
serverId: server.server_id,
|
||||
serverAlias: name,
|
||||
scopes,
|
||||
onSuccess: useCallback(() => onConnect(server.server_id), [onConnect, server.server_id]),
|
||||
});
|
||||
};
|
||||
const { startOAuthFlow, status } = useUserMcpOAuthFlow(flowOptions);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoStartKey === null || status !== "idle" || getSecureItem(autoStartKey) !== null) return;
|
||||
|
|
|
|||
|
|
@ -4782,6 +4782,7 @@ export interface ConnectFlowStatus {
|
|||
server_id: string | null;
|
||||
server_name: string | null;
|
||||
connected: boolean | null;
|
||||
requested_scopes?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
71
ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.test.tsx
Normal file
71
ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.test.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as networking from "@/components/networking";
|
||||
import { setSecureItem } from "@/utils/secureStorage";
|
||||
import { useUserMcpOAuthFlow } from "./useUserMcpOAuthFlow";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
exchangeMcpOAuthToken: vi.fn(),
|
||||
storeMCPOAuthUserCredential: vi.fn(),
|
||||
registerMcpOAuthClient: vi.fn(),
|
||||
buildMcpOAuthAuthorizeUrl: vi.fn(),
|
||||
getProxyBaseUrl: () => "",
|
||||
serverRootPath: "",
|
||||
}));
|
||||
vi.mock("@/lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
describe("user OAuth granted scopes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.sessionStorage.clear();
|
||||
const flowState = {
|
||||
state: "callback-state",
|
||||
codeVerifier: "verifier",
|
||||
serverId: "server-1",
|
||||
redirectUri: "https://app.example/callback",
|
||||
scopes: ["tools.write"],
|
||||
};
|
||||
setSecureItem("litellm-user-mcp-oauth-flow-state", JSON.stringify(flowState));
|
||||
setSecureItem(
|
||||
"litellm-user-mcp-oauth-result",
|
||||
JSON.stringify({
|
||||
state: "callback-state",
|
||||
code: "provider-code",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["tools.read tools.write", ["tools.read", "tools.write"]],
|
||||
["tools.read", ["tools.read"]],
|
||||
["", []],
|
||||
[undefined, ["tools.write"]],
|
||||
])("persists the actual grant %s after reconnect", async (scope, expected) => {
|
||||
vi.mocked(networking.exchangeMcpOAuthToken).mockResolvedValue({
|
||||
access_token: "vendor-token",
|
||||
refresh_token: "vendor-refresh",
|
||||
scope,
|
||||
});
|
||||
const onSuccess = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useUserMcpOAuthFlow({
|
||||
accessToken: "caller",
|
||||
serverId: "server-1",
|
||||
onSuccess,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(result.current.status).toBe("success"));
|
||||
const expectedCredential = {
|
||||
access_token: "vendor-token",
|
||||
refresh_token: "vendor-refresh",
|
||||
expires_in: undefined,
|
||||
scopes: expected,
|
||||
};
|
||||
expect(networking.storeMCPOAuthUserCredential).toHaveBeenCalledExactlyOnceWith(
|
||||
"caller",
|
||||
"server-1",
|
||||
expectedCredential,
|
||||
);
|
||||
expect(onSuccess).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
@ -68,6 +68,9 @@ const getStorage = (key: string): string | null => {
|
|||
return getSecureItem(key);
|
||||
};
|
||||
|
||||
const grantedScopes = (scope: unknown, requested: string[] | undefined): string[] | undefined =>
|
||||
typeof scope === "string" ? scope.split(" ").filter(Boolean) : requested;
|
||||
|
||||
export const useUserMcpOAuthFlow = ({
|
||||
accessToken,
|
||||
serverId,
|
||||
|
|
@ -216,7 +219,7 @@ export const useUserMcpOAuthFlow = ({
|
|||
access_token: token.access_token,
|
||||
refresh_token: token.refresh_token,
|
||||
expires_in: token.expires_in,
|
||||
scopes: flowState.scopes,
|
||||
scopes: grantedScopes(token.scope, flowState.scopes),
|
||||
});
|
||||
|
||||
setStatus("success");
|
||||
|
|
|
|||
1
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
1
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -51945,6 +51945,7 @@ export interface operations {
|
|||
code_challenge_method?: string | null;
|
||||
response_type?: string | null;
|
||||
resource?: string | null;
|
||||
scope?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue