Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions litellm/llms/custom_httpx/http_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,26 @@ async def get(
)
return response

async def head(
self,
url: str,
params: Optional[dict] = None,
headers: Optional[dict] = None,
follow_redirects: Optional[bool] = None,
):
# Set follow_redirects to UseClientDefault if None
_follow_redirects = (
follow_redirects if follow_redirects is not None else USE_CLIENT_DEFAULT
)

params = params or {}
params.update(HTTPHandler.extract_query_params(url))

response = await self.client.head(
url, params=params, headers=headers, follow_redirects=_follow_redirects # type: ignore
)
return response
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

@track_llm_api_timing()
async def post(
self,
Expand Down
122 changes: 121 additions & 1 deletion litellm/proxy/_experimental/mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,17 @@
get_server_prefix,
iter_known_server_prefixes,
)
from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
httpxSpecialProvider,
)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
from litellm.proxy.litellm_pre_call_utils import (
LiteLLMProxyRequestSetup,
get_chain_id_from_headers,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp import MCPAuth, MCPSpecVersion
from litellm.types.mcp_server.mcp_server_manager import MCPInfo, MCPServer
from litellm.types.utils import CallTypes, StandardLoggingMCPToolCall
from litellm.utils import Rules, client, function_setup
Expand Down Expand Up @@ -2754,6 +2758,115 @@ async def _apply_toolset_scope(
)
return user_api_key_auth.model_copy(update={"object_permission": updated_op})

def _get_forwarded_auth_from_scope(scope: Scope) -> Optional[str]:
"""Return the raw Authorization header value from the ASGI scope, or None."""
for key, value in scope.get("headers", []):
if key.lower() == b"authorization":
return value.decode("latin-1")
return None

async def _probe_upstream_auth(
url: str,
auth_header: str,
timeout: float = 5.0,
) -> tuple:
"""JSON-RPC initialize-probe the upstream URL to check whether the token is accepted.

Uses POST so StreamableHTTP MCP servers run the same auth path as a
real client request. Returns (status_code, www_authenticate).
Fails-open with (200, None) on network errors so a transient hiccup
does not block valid requests.
"""
try:
client = get_async_httpx_client(
llm_provider=httpxSpecialProvider.MCP,
params={"timeout": timeout},
)
probe_payload = {
"jsonrpc": "2.0",
"id": "litellm-mcp-auth-probe",
"method": "initialize",
"params": {
"protocolVersion": MCPSpecVersion.jun_2025.value,
"capabilities": {},
"clientInfo": {
"name": "litellm-mcp-auth-probe",
"version": "1.0.0",
},
},
}
resp = await client.client.post( # type: ignore[attr-defined]
url,
headers={
"Authorization": auth_header,
"Accept": "application/json, text/event-stream",
},
json=probe_payload,
)
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
return resp.status_code, resp.headers.get("www-authenticate")
except Exception as exc:
verbose_logger.debug(
f"_probe_upstream_auth: probe to {url} failed ({exc}), allowing request through"
)
return 200, None
Comment thread
Sameerlite marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Probe bypasses AsyncHTTPHandler.post() and will silently fail-open if refactored

AsyncHTTPHandler.post() calls response.raise_for_status() internally, so calling client.post() for a 401/403 upstream would raise httpx.HTTPStatusError, which would then be caught by the broad except Exception block and cause the probe to return (200, None) — silently defeating the entire feature. The workaround (client.client.post) accesses the internal httpx client directly (confirmed by the # type: ignore[attr-defined]) to avoid this. If AsyncHTTPHandler is ever refactored (e.g., the self.client attribute renamed), the probe fails-open on every request with no warning.

The correct fix is to use the public AsyncHTTPHandler.post() and handle the status error before the catch-all — add import httpx and restructure as:

  • resp = await client.post(url, headers=..., json=..., timeout=timeout)
  • Add except httpx.HTTPStatusError as exc: return exc.response.status_code, exc.response.headers.get("www-authenticate") before the general except Exception

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 560c22b — the probe now uses the public AsyncHTTPHandler.post() and catches httpx.HTTPStatusError explicitly before the broad fail-open except Exception, so a 401/403 from upstream is no longer at risk of being silently swallowed if the handler refactors its internal client.


async def _check_passthrough_upstream_auth(
scope: Scope,
user_api_key_auth: Optional[UserAPIKeyAuth],
mcp_servers: Optional[List[str]],
client_ip: Optional[str],
) -> None:
"""Probe pass-through upstream servers in parallel before the MCP session starts.

Only servers the caller's key is already authorized to reach are probed —
the list is derived from _get_allowed_mcp_servers so that a user cannot
trigger an upstream probe against a server their key is not permitted for.

The MCP SDK commits HTTP 200 headers before invoking handlers, so a 401
can only be returned before that point. This function raises HTTPException(401)
with a WWW-Authenticate header if any upstream rejects the client token.
Fails-open: network errors are logged and the request is allowed through.
"""
forwarded_auth = _get_forwarded_auth_from_scope(scope)
if not forwarded_auth:
return

# Use the authorized server set, not the raw user-supplied names, so that
# a caller cannot force a probe to a server their key is not allowed to use.
allowed_servers = await _get_allowed_mcp_servers(
user_api_key_auth=user_api_key_auth,
mcp_servers=mcp_servers,
client_ip=client_ip,
)
passthrough_servers = [
srv
for srv in allowed_servers
if srv.extra_headers
Comment thread
veria-ai[bot] marked this conversation as resolved.
and any(h.lower() == "authorization" for h in srv.extra_headers)
Comment thread
veria-ai[bot] marked this conversation as resolved.
]
if not passthrough_servers:
return

probe_results = await asyncio.gather(
*[
_probe_upstream_auth(srv.url or "", forwarded_auth)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: Proxy API key forwarded upstream

forwarded_auth is taken from the raw request Authorization header, but that same header is also accepted as the LiteLLM API key when x-litellm-api-key is not present. A configured token-forwarding MCP server can now receive and reuse a user's LiteLLM proxy key during this preflight probe; only run the probe when the proxy was authenticated with a separate credential, or pass an upstream token value that process_mcp_request has confirmed is not the proxy API key.

for srv in passthrough_servers
]
)
request = StarletteRequest(scope)
base_url = get_request_base_url(request)
for srv, (probe_status, _) in zip(passthrough_servers, probe_results):
if probe_status in (401, 403):
authorization_uri = (
f"Bearer authorization_uri="
f"{base_url}/.well-known/oauth-authorization-server/{srv.name}"
)
raise HTTPException(
status_code=401,
detail="Unauthorized",
headers={"WWW-Authenticate": authorization_uri},
)
Comment thread
Sameerlite marked this conversation as resolved.
Outdated

async def handle_streamable_http_mcp(
scope: Scope, receive: Receive, send: Send
) -> None:
Expand Down Expand Up @@ -2827,6 +2940,13 @@ async def handle_streamable_http_mcp(
user_api_key_auth, active_toolset_id
)

# Pre-flight auth check for pass-through servers. Must run after
# toolset scoping so the probe list is derived from the fully-authorized
# server set, not the raw user-supplied names.
await _check_passthrough_upstream_auth(
scope, user_api_key_auth, mcp_servers, _client_ip
)
Comment thread
cursor[bot] marked this conversation as resolved.

# Inject masked debug headers when client sends x-litellm-mcp-debug: true
_debug_headers = MCPDebug.maybe_build_debug_headers(
raw_headers=raw_headers,
Expand Down
33 changes: 33 additions & 0 deletions tests/test_litellm/llms/custom_httpx/test_http_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,39 @@
)


@pytest.mark.asyncio
async def test_async_head_returns_response_without_raise_for_status():
captured_request = None

async def mock_handler(request: httpx.Request) -> httpx.Response:
nonlocal captured_request
captured_request = request
return httpx.Response(
401,
request=request,
headers={"www-authenticate": 'Bearer realm="test"'},
)

litellm_handler = AsyncHTTPHandler()
await litellm_handler.client.aclose()
litellm_handler.client = httpx.AsyncClient(
transport=httpx.MockTransport(mock_handler)
)
try:
response = await litellm_handler.head(
"https://upstream.example/mcp",
headers={"Authorization": "Bearer some-token"},
)

assert response.status_code == 401
assert response.headers["www-authenticate"] == 'Bearer realm="test"'
assert captured_request is not None
assert captured_request.method == "HEAD"
assert captured_request.headers["Authorization"] == "Bearer some-token"
finally:
await litellm_handler.close()


@pytest.mark.asyncio
async def test_async_post_streaming_status_error_should_not_wait_forever_for_body(
monkeypatch,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3256,3 +3256,79 @@ async def capture_create_mcp_client(*args, **kwargs):
), "P2 API consistency issue: expected None for empty extra_headers, got: " + str(
captured_extra_headers
)


# ---------------------------------------------------------------------------
# Pre-flight upstream auth check tests
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_probe_upstream_auth_returns_upstream_status():
"""_probe_upstream_auth forwards the status code from the upstream server."""
from litellm.proxy._experimental.mcp_server.server import _probe_upstream_auth

mock_response = MagicMock()
mock_response.status_code = 401
mock_response.headers = {"www-authenticate": 'Bearer realm="test"'}

mock_client = AsyncMock()
mock_client.client.post = AsyncMock(return_value=mock_response)

with patch(
"litellm.proxy._experimental.mcp_server.server.get_async_httpx_client",
return_value=mock_client,
):
status, www_auth = await _probe_upstream_auth(
"http://upstream/mcp", "Bearer some-token"
)

assert status == 401
assert www_auth == 'Bearer realm="test"'
mock_client.client.post.assert_awaited_once()
_, kwargs = mock_client.client.post.call_args
assert kwargs["headers"]["Authorization"] == "Bearer some-token"
assert kwargs["json"]["method"] == "initialize"
Comment thread
cursor[bot] marked this conversation as resolved.


@pytest.mark.asyncio
async def test_probe_upstream_auth_fails_open_on_network_error():
"""_probe_upstream_auth returns (200, None) when the network call fails."""
from litellm.proxy._experimental.mcp_server.server import _probe_upstream_auth

mock_client = AsyncMock()
mock_client.client.post = AsyncMock(side_effect=Exception("connection refused"))

with patch(
"litellm.proxy._experimental.mcp_server.server.get_async_httpx_client",
return_value=mock_client,
):
status, www_auth = await _probe_upstream_auth(
"http://upstream/mcp", "Bearer some-token"
)

assert status == 200
assert www_auth is None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Probe tests mock the internal client instead of the public method

Both probe tests set up mock_client.client.post, but _probe_upstream_auth now calls the public client.post(...) (AsyncHTTPHandler.post()), which is a completely separate mock attribute. AsyncHTTPHandler.post() calls self.client.send() then raise_for_status(), meaning a real 401 would arrive as an httpx.HTTPStatusError, not as a response object.

For test_probe_upstream_auth_returns_upstream_status: await mock_client.post(...) returns mock_client.post.return_value (an AsyncMock), so resp.status_code is a MagicMock, not 401; the assertion fails.

For test_probe_upstream_auth_fails_open_on_network_error: mock_client.client.post is wired to raise but mock_client.post is not — no exception is ever thrown, so the fail-open path is never exercised and status is again a MagicMock rather than 200.

To correctly test the 401 case, mock_client.post should be configured to raise httpx.HTTPStatusError with a 401 response (since AsyncHTTPHandler.post() calls raise_for_status()). For the fail-open test, mock_client.post itself should be wired with side_effect=Exception(...).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in c07b62a + 28eda2a — tests now mock client.post (matching the production call) and there is a new test_probe_upstream_auth_surfaces_httpx_status_error that exercises the httpx.HTTPStatusError path raised by raise_for_status().



def test_get_forwarded_auth_from_scope_extracts_header():
"""_get_forwarded_auth_from_scope returns the Authorization value."""
from litellm.proxy._experimental.mcp_server.server import (
_get_forwarded_auth_from_scope,
)

scope = {
"headers": [
(b"content-type", b"application/json"),
(b"authorization", b"Bearer my-token"),
]
}
assert _get_forwarded_auth_from_scope(scope) == "Bearer my-token"


def test_get_forwarded_auth_from_scope_returns_none_when_missing():
from litellm.proxy._experimental.mcp_server.server import (
_get_forwarded_auth_from_scope,
)

assert _get_forwarded_auth_from_scope({"headers": []}) is None
Loading