feat(mcp): relay upstream registration errors to the client on the dcr_bridge arm

This commit is contained in:
Tin Chi Lo 2026-07-10 09:21:52 -07:00
parent b8d1b395d2
commit 849ceb0c19
2 changed files with 106 additions and 0 deletions

View file

@ -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()

View file

@ -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