mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
feat(mcp): relay upstream registration errors to the client on the dcr_bridge arm
This commit is contained in:
parent
b8d1b395d2
commit
849ceb0c19
2 changed files with 106 additions and 0 deletions
|
|
@ -989,6 +989,21 @@ async def _persist_dcr_client_registration(
|
|||
return "failed"
|
||||
|
||||
|
||||
_MAX_UPSTREAM_ERROR_CHARS = 500
|
||||
|
||||
|
||||
def _safe_upstream_error_detail(response: httpx.Response) -> str:
|
||||
"""Bounded plaintext summary of an upstream registration failure for the client.
|
||||
|
||||
RFC 7591 error bodies are small JSON objects (``error`` / ``error_description``); relaying the
|
||||
text lets the client read the real reason instead of a bare 500, and the length bound keeps a
|
||||
hostile or oversized upstream body from bloating the gateway response."""
|
||||
body = response.text
|
||||
if not body:
|
||||
return response.reason_phrase or "upstream registration failed"
|
||||
return body[:_MAX_UPSTREAM_ERROR_CHARS]
|
||||
|
||||
|
||||
async def register_client_with_server(
|
||||
request: Request,
|
||||
mcp_server: MCPServer,
|
||||
|
|
@ -1050,6 +1065,8 @@ async def register_client_with_server(
|
|||
status_code=502,
|
||||
detail="MCP upstream registration endpoint returned no response",
|
||||
)
|
||||
if bridge_relay and response.status_code >= 400:
|
||||
raise HTTPException(status_code=response.status_code, detail=_safe_upstream_error_detail(response))
|
||||
response.raise_for_status()
|
||||
|
||||
token_response = response.json()
|
||||
|
|
|
|||
|
|
@ -3866,6 +3866,7 @@ async def _bridge_register_response(server, request_payload, persist_credentials
|
|||
)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.json.return_value = {
|
||||
"client_id": "upstream-issued-client",
|
||||
"redirect_uris": request_payload.get("redirect_uris", []),
|
||||
|
|
@ -3934,6 +3935,94 @@ async def test_register_bridge_relay_requires_redirect_uris():
|
|||
assert "redirect_uris" in str(exc.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_bridge_relay_surfaces_upstream_error_not_500():
|
||||
"""A bridge relay registration the upstream rejects must surface the upstream status and its
|
||||
RFC 7591 error body to the client, not a bare 500 that hides the real reason."""
|
||||
import httpx
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
register_client_with_server,
|
||||
)
|
||||
|
||||
error_response = MagicMock()
|
||||
error_response.status_code = 400
|
||||
error_response.text = '{"error":"invalid_redirect_uri","error_description":"redirect_uri not allowed"}'
|
||||
error_response.raise_for_status = MagicMock(
|
||||
side_effect=httpx.HTTPStatusError("bad", request=MagicMock(), response=error_response)
|
||||
)
|
||||
mock_async_client = MagicMock()
|
||||
mock_async_client.post = AsyncMock(return_value=error_response)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
||||
return_value=mock_async_client,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._reuse_persisted_dcr_client_if_available",
|
||||
new_callable=AsyncMock,
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await register_client_with_server(
|
||||
request=_bridge_mock_request(),
|
||||
mcp_server=_bridge_server(),
|
||||
client_name="Claude",
|
||||
grant_types=None,
|
||||
response_types=None,
|
||||
token_endpoint_auth_method=None,
|
||||
client_redirect_uris=[_BRIDGE_CLIENT_REDIRECT],
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert "invalid_redirect_uri" in str(exc.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_non_bridge_upstream_error_still_raises_500():
|
||||
"""Non-bridge DCR keeps its pre-change behavior: raise_for_status propagates so the flag-off
|
||||
contract is byte-identical; only the bridge relay arm relays the upstream status."""
|
||||
import httpx
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
register_client_with_server,
|
||||
)
|
||||
|
||||
error_response = MagicMock()
|
||||
error_response.status_code = 400
|
||||
error_response.text = '{"error":"invalid_client_metadata"}'
|
||||
error_response.raise_for_status = MagicMock(
|
||||
side_effect=httpx.HTTPStatusError("bad", request=MagicMock(), response=error_response)
|
||||
)
|
||||
mock_async_client = MagicMock()
|
||||
mock_async_client.post = AsyncMock(return_value=error_response)
|
||||
|
||||
oauth2_server = _bridge_server(auth_type=MCPAuth.oauth2, dcr_bridge=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
||||
return_value=mock_async_client,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._reuse_persisted_dcr_client_if_available",
|
||||
new_callable=AsyncMock,
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await register_client_with_server(
|
||||
request=_bridge_mock_request(),
|
||||
mcp_server=oauth2_server,
|
||||
client_name="Claude",
|
||||
grant_types=None,
|
||||
response_types=None,
|
||||
token_endpoint_auth_method=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_bridge_relay_never_persists():
|
||||
"""Relayed registrations belong to individual clients; persisting one as the server's own DCR
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue