maybe_build_debug_headers used to promote client_ip=None to
INTERNAL_REQUEST before resolving the server name, on the theory that
debug-header generation is passive observability and shouldn't drop
metadata when IP extraction failed. But when x-litellm-mcp-debug:true
is sent on a request whose IP can't be attributed, that bypass leaks
x-mcp-debug-outbound-url / x-mcp-debug-server-auth-type for any
internal-only server named in the request — even though the IP gate
would otherwise hide it.
Pass client_ip straight through. The gate now fails closed for
internal-only servers when IP extraction fails; the debug response
falls back to "(unknown)" / "(none)" instead of leaking real upstream
metadata. Internal callers (admin debug paths) still resolve normally
because their request handlers supply a real internal IP.
Add regression tests for both the leak-prevention path and the
internal-IP resolution path.
The fail-closed gate previously denied access for any server when
client_ip was None — including legitimately-public servers. A real
HTTP request whose IP couldn't be extracted (ASGI middleware nulling
request.client, broken X-Forwarded-* parsing, etc.) would 404 against
a public OAuth endpoint with no actionable diagnostic for operators.
Reorder _is_server_accessible_from_ip to check the public-server
short-circuits (available_on_public_internet and
litellm.public_mcp_servers) before failing closed on None. Non-public
servers still fail closed when client_ip is None; the
mcp_allow_unknown_client_ip opt-out still works as a wider escape
hatch for operators who can't fix IP extraction.
Also remove the None short-circuits in filter_server_ids_by_ip_with_info
and get_filtered_registry so they delegate to the per-server gate,
which now handles None correctly.
Update test_gate_contract to expect public+None → True and add
test_public_server_reachable_when_ip_unknown and
test_no_ip_lets_public_through_blocks_private for the new semantics.
_get_cached_temporary_mcp_server_or_404 (used by
/server/oauth/{server_id}/{authorize,token,register}) used to do:
server = manager.get_mcp_server_by_id(server_id) \
or manager.get_mcp_server_by_name(server_id, client_ip=client_ip)
The id-lookup branch returned servers without applying IP gating, so an
external caller hitting any of those OAuth endpoints with the UUID of
an internal-only server bypassed the IP restriction — the name-lookup
fallback was the only one gated. Apply
_is_server_accessible_from_ip(server, client_ip) to the id-lookup
result before falling through.
Add a regression test that mocks the gate to deny external IPs and
asserts a 404 when an external request hits the UUID of an
internal-only server.
Two compat/safety items flagged by Greptile in PR review:
1. Fail-closed IP gating had no opt-out flag. Operators behind ASGI
middleware or load-balancers where request.client is legitimately
None will get unexpected 403s with no recourse short of code
changes. Add general_settings.mcp_allow_unknown_client_ip (default
false). When true, missing client_ip is promoted to INTERNAL_REQUEST
at the gate. Centralized in _resolve_unknown_client_ip and applied
at the three entry points (_is_server_accessible_from_ip,
filter_server_ids_by_ip_with_info, get_filtered_registry).
2. AsyncHTTPHandler.post() now passes follow_redirects through to
single_connection_post_request on the connection-error retry path.
Without this, a transient RemoteProtocolError followed by a 30x on
reconnect could bypass the SSRF redirect block on the MCP OAuth
/token and /register flows.
After fail-closing the IP gate on missing client_ip, three off-diff
callers of get_mcp_server_by_name still defaulted to None and would
silently return no server:
- responses/mcp/litellm_proxy_mcp_handler.py: server-side name vs
toolset disambiguation; access control is enforced downstream by
_get_allowed_mcp_servers_from_mcp_server_names. Pass INTERNAL_REQUEST.
- _experimental/mcp_server/mcp_debug.py: passive debug-header builder
called after upstream access checks. If the upstream request handler
couldn't extract a client IP, fall back to INTERNAL_REQUEST so debug
metadata isn't silently dropped.
- management_endpoints/mcp_management_endpoints.py: programmatic
callers of _get_cached_temporary_mcp_server_or_404 (request=None)
bypass the gate explicitly; real HTTP callers still get the
extracted IP.
Also disambiguate the _ip_blocked debug log so operators see the
actionable message: "fix request-IP extraction" when client_ip is
None vs. "set available_on_public_internet: true" when a real IP is
blocked. The previous combined message pointed operators toward
exposing internal servers when the real cause was IP-extraction
failure.
The /token and /register OAuth endpoints are reachable without a
LiteLLM API key — they sit mid-OAuth-handshake. When validate_url
blocked an internal destination, the raw SSRFError ("URL targets a
blocked address (10.0.0.50). …") was wrapped verbatim into the
HTTPException detail and returned to the unauthenticated caller,
handing them the resolved internal address of the operator's IdP —
exactly the reconnaissance the SSRF guard is meant to deny.
Log the real reason at WARNING for operators and return a generic
"the destination resolves to a blocked address" detail to the caller.
Add regression tests asserting the resolved IP does not appear in
the HTTPException detail for either role.
Previously _get_allowed_mcp_servers fell back to INTERNAL_REQUEST when
both the explicit client_ip and the auth-context IP were missing,
defeating the new fail-closed gate at the wrapper layer. An external
request with no attributable client IP could reach internal-only MCP
servers via tool/list/resource calls.
Drop the auto-promote: pass None through, which now fails closed in
filter_server_ids_by_ip_with_info. Internal callers must opt in to
INTERNAL_REQUEST explicitly.
Update test_mcp_routing_with_conflicting_alias_and_group_name to mock
_get_client_ip_from_context with a real internal IP — the unit test has
no HTTP request context so it would otherwise be filtered out by the
fail-closed gate.
Update test_mcp_ip_filtering tests to assert the new fail-closed
behavior on missing IP and the INTERNAL_REQUEST escape hatch.
The function's docstring at server.py:862-864 documents that when both
client_ip is None and the auth context is empty, the call is internal
(admin debug, registry maintenance) and IP filtering is intentionally
skipped. After the previous commit made filter_server_ids_by_ip_with_info
fail closed on None, that documented path silently returned an empty
allowed-server list instead of bypassing the filter.
Pass INTERNAL_REQUEST when both fall-throughs return None so the
documented internal-caller behaviour matches the new wrapper contract.
External request handlers that pass None unintentionally still hit the
fail-closed path the previous commit added.
Veria-AI flagged that two more wrappers — get_filtered_registry and
filter_server_ids_by_ip(_with_info) — still treated client_ip=None as
"no filter, return everything." That left request paths that use them
(_resolve_oauth2_server_for_root_endpoints when get_mcp_client_ip
returned None, /public/mcp_hub registry list, server.py's MCP request
preflight) able to auto-select or list internal-only servers when IP
extraction failed.
Both wrappers now follow the same contract as
_is_server_accessible_from_ip and get_mcp_server_by_name: None fails
closed (empty result), INTERNAL_REQUEST bypasses gating, real IPs
apply the existing filter.
All 5 callers already extract client_ip from the request and pass it,
so behaviour for valid external requests is unchanged. The change only
affects the previously-fail-open path where extraction returned None.
Two follow-ups to address Veria-AI findings on the PR.
(1) Disable redirect-following on the OAuth outbound POSTs.
validate_url only inspects the initial URL; httpx clients default to
follow_redirects=True, so a malicious 30x from the validated host could
bounce the proxy to an internal target. Add follow_redirects to the
AsyncHTTPHandler.post wrapper (mirroring the existing get) and pass
follow_redirects=False from both /token and /register flows.
(2) Make get_mcp_server_by_name fail closed on None client_ip. The
wrapper previously translated None to INTERNAL_REQUEST internally to
preserve the "None means internal" convention, but that meant any
external request handler that forgot to pass an IP silently bypassed
gating. Update the wrapper to require an explicit sentinel for
internal callers; update the four external callers in
auth/user_api_key_auth_mcp.py and rest_endpoints.py to pass
INTERNAL_REQUEST (where the lookup is metadata-only) or the real
extracted client_ip.
Adjust the test fixture in test_discoverable_endpoints.py to return
INTERNAL_REQUEST instead of None so OAuth flow tests bypass IP gating
explicitly. Update two stub lambdas in test_rest_endpoints.py to accept
the new client_ip kwarg (CLAUDE.md: keep monkeypatch stubs in sync with
real signatures).
- Cast client_ip to str at the bottom of _is_server_accessible_from_ip so
mypy sees the narrowing the early returns already perform. The runtime
shape is unchanged — INTERNAL_REQUEST and None are both handled in the
early branches above.
- Distinguish "client_ip_unknown" from "ip_filtering" in the
rest_endpoints 403 response. When IP extraction fails on a public
server the previous error told the operator to set
available_on_public_internet=True, which was already set; the new
message points them at use_x_forwarded_for / mcp_trusted_proxy_ranges.
- Preserve the explicit port in the Host header when
litellm.user_url_validation is False. The opt-out path previously
dropped the port, sending a Host that didn't match the connection
target for non-default-port URLs.
Tighten the role parameter on _validate_mcp_oauth_outbound_url to
Literal["token", "registration"] so a future caller typo is caught at
type-check time. Replace the mixed Optional / PEP 604 annotation on
_is_server_accessible_from_ip with a single Union form. Trim the comment
on the get_mcp_server_by_name translation to keep the WHY and drop the
restated WHAT. Fold the four single-axis IP-gating tests into one
parametrize that covers visibility × client_ip kind in 6 cases.
No behavioural change.
The OAuth proxy endpoints /authorize, /token, and /register sit mid-OAuth-
handshake and can't require Depends(user_api_key_auth). They forward to
admin-configured token_url and registration_url. An unauthenticated caller
hitting /token or /register made the proxy POST to whatever URL the admin
configured for the MCP server, which let an internal IdP be probed via the
proxy if the admin had registered one.
Validate the outbound URL through litellm_core_utils.url_utils.validate_url
before each POST in register_client_with_server and exchange_token_with_server.
The helper resolves DNS, blocks RFC1918 / loopback / link-local destinations,
honours the existing user_url_allowed_hosts allowlist, and rewrites the URL
to the validated IP to defeat DNS rebinding. Operators who need to point at
an internal IdP can opt in via the same allowlist that other outbound
URL-validation sites use, or set litellm.user_url_validation = False.
Separately, _is_server_accessible_from_ip used to fail open when client_ip
was None, which let request handlers that couldn't determine a client IP
reach internal-only servers. Lock the contract: None now fails closed and
internal callers must pass the new INTERNAL_REQUEST sentinel to bypass IP
gating. get_mcp_server_by_name keeps its existing "None means internal"
wrapper convention by translating to the sentinel internally, so internal
callers (auth, debug, registry maintenance) continue to work unchanged.
The user-facing callsites in rest_endpoints that previously short-circuited
on client_ip is None now hit the gate and inherit the fail-closed behaviour.
* fix(responses): map chat tool_choice to Responses API when bridging from completions
OpenAI /v1/responses rejects tool_choice.function. Normalize forced-function
choice from chat shape to {type, name} in LiteLLMResponsesTransformationHandler.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(responses): strip tool_choice.function when top-level name is set
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Anthropic rejects tool_use/tool_result when tools is omitted. Always map
and attach the dummy tool in transform_request so CLIs work without
litellm.modify_params.
- Add unit test for transform_request dummy tool with modify_params off
- Adjust parallel function calling integration expectations: Bedrock
Converse still requires modify_params for this path
Co-authored-by: Cursor <cursoragent@cursor.com>
Reject fnmatch wildcards on non-scope claims when the claim string contains
whitespace so malformed iss values cannot match patterns like trusted.*.
Merge every entry when team_id_jwt_field resolves to a list instead of
keeping only the first element.
Co-authored-by: Cursor <cursoragent@cursor.com>
- Extend responses_api_bridge_check when reasoning_effort + summary aliases
(including nested extra_body) without tools
- Merge summary into reasoning_effort for responses bridge; helpers in utils
- Strip summary aliases in GPT-5 chat mapping when not bridged
- Tests for bridge + merge behavior
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(ui): omit allowed_routes from key edit save when unchanged
When a team admin opens Edit Settings on a key with key_type=AI APIs and
saves without changing anything, the UI re-sends the existing allowed_routes
value, which the backend's _check_allowed_routes_caller_permission gate
rejects for non-proxy-admins (LIT-2681).
Strip allowed_routes from the patch in handleSubmit when it deep-equals the
original keyData.allowed_routes. The backend treats absence as "leave alone,"
so no-op saves now succeed for non-admins. Admins explicitly editing the
field still send the new value.
* fix(ui): order-insensitive allowed_routes diff + cover null-original case
Address Greptile review:
- Switch the "is allowed_routes unchanged" check to a Set-based comparison so
a server-side reorder of the array doesn't register as a user edit and
re-trigger LIT-2681.
- Add two regression tests: (1) keyData.allowed_routes is null and the form
is untouched — patch should strip the field; (2) server returned routes in
a different order than the user originally entered — patch should still
recognize the value as unchanged.
* chore(ui): strip ticket refs and tighten comments in key edit fix
- Remove internal-tracker references from in-code comments
- Tighten the WHY comment in handleSubmit to two lines
- Drop redundant test-block comments — test names already describe the case
* fix(ui): annotate Set<string> generic in allowed_routes diff to fix tsc
- Remove litellm-js/proxy and litellm-js/spend-logs TypeScript packages that provided Cloudflare Worker proxy and Node.js spend logging services, as these are no longer maintained
- Remove deprecated Docker variants (Dockerfile.alpine, Dockerfile.dev, Dockerfile.custom_ui, Dockerfile.health_check, Dockerfile.ghcr_base) that have been superseded by the primary Dockerfile
- Remove legacy Kubernetes manifests (kub.yaml, service.yaml) from deploy/kubernetes in favor of the Helm chart
- Remove stale index.yaml Helm chart index pinned to an old version (v1.43.18)
- Remove dev_config.yaml development configuration file that contained hardcoded credentials and example endpoints
- Clean up ~3,500 lines of unused code and configuration to reduce repository maintenance burden
Co-authored-by: Yassin Kortam <yassinkortam@g.ucla.edu>
Our `uv.lock` already resolves jinja2 to 3.1.6, so Docker / CI installs
get that version. The `pyproject.toml` floor was lagging at 3.1.0,
which means downstream consumers using `--resolution=lowest-direct` or
older constraint files can land on 3.1.0-3.1.5 instead of the version
we actually test against.
Aligns the declared floor with the resolved version so external
installers see the same baseline our test matrix exercises.
`uv lock` diff is metadata-only (no resolved-version drift).