Commit graph

562 commits

Author SHA1 Message Date
mateo-berri
43c838f4b9 Merge branch 'litellm_internal_staging' into litellm_decrease_anys_opus5 2026-08-29 21:41:04 -07:00
devin-ai-integration[bot]
f0340fef16
feat(mcp_gateway): add RFC 7662 introspection for gateway session tokens (#38726)
* feat(mcp_gateway): add RFC 7662 introspection for gateway session tokens

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(mcp_gateway): omit Bearer token_type for refresh introspection and allow mcp-scoped keys

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(mcp_gateway): cover introspection of RS256-signed session tokens

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(mcp_gateway): load the discoverable router on a cold /introspect request

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore(openapi): regenerate lazy snapshot and schema.d.ts for /introspect

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-29 12:56:04 -07:00
devin-ai-integration[bot]
30efcfd684
feat(mcp): support asymmetric (RS256) signing for MCP gateway session tokens (#38728)
* feat(mcp): support RS256 signing for MCP gateway session tokens

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style: ruff format session token modules

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(mcp): enforce key strength on rotated public keys and unique kids

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-29 10:18:59 -07:00
mateo-berri
8d4620649f Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_decrease_anys_opus5
# Conflicts:
#	basedpyright-code-budget.json
#	litellm/caching/valkey_semantic_cache.py
#	litellm/integrations/compression_interception/handler.py
#	litellm/integrations/custom_logger.py
#	litellm/llms/custom_httpx/container_handler.py
#	litellm/llms/infinity/rerank/transformation.py
#	litellm/proxy/agent_endpoints/agent_registry.py
#	litellm/repositories/base_repository.py
#	litellm/repositories/credentials_repository.py
#	litellm/repositories/team_repository.py
#	ruff-strict-budget.json
#	type-discipline-budget.json
2026-08-29 06:03:33 -07:00
yucheng-berri
2ef77f30e3
fix: enforce MCP toolsets attached to a team, org, or internal user (#38488)
* fix: enforce MCP toolsets attached to a team, org, or internal user

object_permission.mcp_toolsets was resolved into servers and tools only at
the key level; every other principal read mcp_tool_permissions and silently
ignored its toolsets. A team/org/user toolset alongside a server grant was
inert (all tools callable), a toolset alone granted nothing, and an inert
team toolset let the org server list substitute for the empty team result,
handing the caller every org server.

Resolve toolsets at each level that resolves mcp_tool_permissions, union
their servers into that level's granted server set, and count a declared
key/team toolset toward has_lower_level_mcp_restrictions so the org list
can only cap, never substitute, even when the toolset resolves empty.

Resolves LIT-5749

* fix: deny when a team's declared MCP toolset cannot be resolved

The team server resolver swallowed UnloadableEntitlementError into an empty
list, so a dangling team toolset dropped the team ceiling instead of denying,
unlike the org and user paths. Re-raise it so the top-level resolver denies.

Also anchor the test-quality suppression comments on the patch opener lines
the gate reads, with per-seam reasons.
2026-08-28 12:06:57 -07:00
mateo-berri
e9cc9c9bc3 fix(a2a): attribute agent search embedding spend to the calling key 2026-08-27 19:11:00 -07:00
mateo-berri
6de53732ee fix(mcp): keep the virtual tool required lists as JSON arrays so /mcp/ tools/call validates 2026-08-27 18:44:17 -07:00
mateo-berri
ca21cf5773 feat(a2a): semantic search over the agent registry via GET /v1/agents?query and an agent_search MCP tool 2026-08-27 18:31:15 -07:00
tin-berri
40ff01b987
feat(mcp): let a resolved OAuth token target a custom upstream header (#38456)
An MCP server behind an API gateway needs two credentials on one request: the
gateway's own token on a private header, and a separate bearer on Authorization
for the server behind it. Every arm that minted or held a token hardcoded
Authorization, and the conflict rule then dropped the operator's static
Authorization to make room, so the second credential never arrived.

ApiKeyConfig already modelled this as header_name plus value_prefix behind a
header() method. Extend that carrier to the four minted-token configs, have each
resolver arm ask its config which header to use instead of naming one, and drop
only the header the resolved credential is about to occupy.

Operators set it per server via upstream_token_header, plumbed through
config.yaml, the credentials blob, the management API and the admin form, on the
M2M, token-exchange, authorization-code and ID-JAG arms. It is non-secret so it
stays plaintext and round-trips on admin reads. Unset keeps today's behaviour.

Moving a credential off Authorization means it stops inheriting what Authorization
gets for free, so the slot now carries those protections itself. httpx drops
Authorization when a redirect crosses origin and keeps every other header, so a
custom slot is dropped by the client on the same condition, mirroring httpx's own
scheme/host/port rule with an agreement test that fails if the two ever diverge.
The v1 path also mirrors the v2 conflict rule, so an injected header cannot shadow
the credential the gateway resolved for that slot.

Which header a credential occupies, and what counts as being that header, was
answered independently in nine places by four hand-rolled comparisons. same_header,
has_header and without_header in litellm/types/mcp.py are now the one owner, shared
by both MCP stacks, and the client derives its slot once instead of three times.

The header name reaches egress verbatim, so the RFC 7230 grammar lives in one
place and is checked where servers are built: a bad value fails the config load
and the management API returns 400, rather than raising while a spec is built
and emptying the aggregate tool list for every other server. A blank means unset,
matching what the endpoint already accepts.
2026-08-27 14:32:01 -07:00
devin-ai-integration[bot]
e16aa9f512
fix(mcp): keep upstream OAuth Authorization when jwt signer hook injects one on tools/call (#38555)
* fix(mcp): keep upstream OAuth Authorization when jwt signer hook injects one on tools/call

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(mcp): only treat server credential as occupying Authorization when it maps to that header

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-27 12:44:36 -07:00
Mateo Wang
98d231c09b
Merge pull request #38398 from daniel-meismer-zocdoc/litellm_mcp_bearer_scheme_refresh
fix(mcp): canonicalize bearer scheme on bridge egress
2026-08-27 09:41:33 -07:00
devin-ai-integration[bot]
ee76c9a6f4
fix(mcp): accept raw x-litellm-api-key on streamable HTTP admission (#38364)
* fix(mcp): accept raw x-litellm-api-key on streamable HTTP admission

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore(mcp): drop comments restating parser behavior

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-26 20:45:51 -07:00
Mateo Wang
39dd46397e
Merge pull request #38379 from BerriAI/litellm_mcp_oauth_admin_entered_authorize_urls
fix(mcp): honor admin-entered OAuth URLs on authorize after issuer yield
2026-08-26 17:11:00 -07:00
mateo-berri
93e7e8d980 fix(mcp): token exchange rejoins discovery for a clientless DCR bridge missing its registration endpoint 2026-08-26 16:26:44 -07:00
mateo-berri
ce8d6f7d25 fix(mcp): token refresh and M2M egress honor the admin-entered token URL 2026-08-26 15:31:45 -07:00
mateo-berri
465ebb1bdd fix(mcp): join discovery for a clientless DCR bridge still missing its registration endpoint 2026-08-26 14:08:09 -07:00
mateo-berri
19e6f03a3c fix(mcp): let root oauth routes defer discovery to the endpoint-gated flow join 2026-08-26 13:39:35 -07:00
Daniel Meismer
cc400502fa fix(mcp): canonicalize bearer scheme on bridge egress
Co-Authored-By: Codex
2026-08-26 16:09:21 -04:00
mateo-berri
8fcbc357e5 fix(mcp): gate deferred oauth discovery on the endpoint each flow needs and read the resolved server
The token exchange no longer joins deferred discovery when the token url is
already stored, so it cannot 503 over an unreachable issuer it needs nothing
from. After a request joins discovery, authorize and token now read the
resolved server for the DCR bridge relay decision and the rest of the flow,
so a registration endpoint resolved mid-request routes a front-door client
to its own redirect binding. The encrypt seam in the issuer-yield authorize
test now uses a real salt key instead of patching an SDK internal.
2026-08-26 12:45:25 -07:00
Mateo Wang
ace28fd97a
Merge pull request #37384 from daniel-meismer-zocdoc/litellm_mcp_dcr_bridge_complete_challenges
fix(mcp): complete DCR bridge OAuth challenges
2026-08-26 12:07:54 -07:00
mateo-berri
229e136783 fix(mcp): honor admin-entered OAuth URLs on authorize after issuer yield
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 11:26:54 -07:00
mateo-berri
764048750e fix(mcp): name invalid_token challenges by the caller's requested spelling 2026-08-26 10:52:28 -07:00
Daniel Meismer
c74a8df52d chore(mcp): satisfy test quality lint
Document the intentional internal seams used by the DCR bridge admission tests and normalize import ordering.\n\nGenerated with AI\n\nCo-Authored-By: Codex
2026-08-25 22:48:22 -04:00
Daniel Meismer
da036ad0f0 fix(mcp): harden DCR bridge admission
Preserve standard Authorization key validation while preventing client MCP credentials from receiving anonymous bridge admission.

Generated with AI

Co-Authored-By: Codex
2026-08-25 22:33:07 -04:00
Daniel Meismer
a66e091cd7 fix(mcp): complete DCR bridge OAuth challenges 2026-08-25 22:33:07 -04:00
Daniel Meismer
c673bcd970 fix(mcp): preserve provider access token lifetime
Co-Authored-By: Codex
2026-08-25 18:41:16 -04:00
yuneng-jiang
6a0d03914c
test: drop the cwd-relative sys.path.insert calls from the test suite (#37802)
* test: drop the cwd-relative sys.path.insert calls from the test suite

TQ003 stands at 1,077 across 1,058 files, and 1,015 of them are the same shape:
sys.path.insert(0, os.path.abspath("../..")) and its deeper siblings. The
argument resolves against the working directory rather than the file, so from
the repo root, where every job runs pytest, it inserts the directory two levels
above the checkout. It has never pointed at litellm. The package is installed
into the environment anyway, which is what actually makes the import work, and
what the rule's message has said all along.

Removing them leaves 1,634 imports of sys and os with no remaining reference,
and those go too, except where another test module imports the name back out of
the file. The rest of TQ003 is 62 call sites that resolve against __file__ or a
variable, which are a different question and are left alone.

Collection is identical either way: 45,871 tests and the same 51 pre-existing
collection errors before and after, and ruff reports no new undefined name.

* test: drop the duplicate imports the sys.path sweep exposed to F811

* test(pre-call-utils): restore the os import the new bedrock tests need
2026-08-22 09:25:58 -07:00
yuneng-jiang
6d34de50eb
Merge pull request #37824 from BerriAI/litellm_retire_dead_test_mirror
test(mcp): retire the last file of the dead tests/litellm mirror
2026-08-21 20:12:56 -07:00
tin-berri
7cb100af63
fix(mcp): resolve admin OAuth sessions to the same server set the connect page shows (#37900)
* fix(mcp): resolve admin OAuth sessions to the same server set the connect page shows

* fix(mcp): bind admitted admin rows through the entitlement ceiling, not the credential scope clause
2026-08-21 18:16:49 -07:00
tin-berri
d193c7aefe
fix(mcp): strip root_path before matching the per-server MCP route spelling (#35576)
* fix(mcp): strip root_path before matching the per-server MCP route spelling

The 401 challenge for a gateway-managed oauth2 MCP server advertises the
protected-resource metadata URL in the spelling the client connected on, so a
strict RFC 9728 section 3 client lands on a document whose `resource` equals the
URL it actually called. That spelling test compared `_original_path` against the
root-relative `/{server}/mcp` shape, but `_original_path` and `scope["path"]`
are raw request-line paths that still carry the deployment's `root_path`

On a SERVER_ROOT_PATH deployment the prefix therefore made the legacy test fail
and every request fell through to the standard `/mcp/{server}` branch. A client
connecting on `/litellm/github/mcp` was pointed at the standard-pattern
document, which serves `resource = {base}/litellm/mcp/github`; that is not the
URL the client called, so a strict client aborts discovery before the MCP
request fires

Route the path through `get_route_relative_request_path` first, which removes
`root_path` on a segment boundary the same way
`litellm.proxy.auth.auth_utils.get_request_route` already does for the rest of
the MCP auth path, so `/litellmfoo` is not truncated under `root_path=/litellm`

* fix(mcp): make the gateway-managed 401 challenge root-path aware

The gateway-managed authorization_code challenge in process_mcp_request
built its AS-metadata URL from two root-path-unaware pieces:

- it matched the caller's spelling against `scope["_original_path"]`, a
  raw request-line path that still carries the deployment prefix, so on a
  SERVER_ROOT_PATH deployment the `/mcp/{server}` branch never matched and
  every request fell through to the legacy one-segment form
- it hardcoded `/.well-known/oauth-authorization-server` without the
  root-path segment the discovery route decorators bake in, so the URL
  404'd under a sub-path deployment regardless of which branch was taken

Route the spelling match through get_route_relative_request_path and the
well-known root through well_known_root_suffix, the same two helpers the
discovery route registrations derive their paths from, so the advertised
URL cannot drift from the route that serves it.

Root-mounted deployments are unaffected: both helpers are no-ops when
SERVER_ROOT_PATH is unset.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-21 15:40:14 -07:00
ryan-crabbe-berri
243ed4393d test: reject assertions on a caught error inside except (ruff PT017)
A test that asserts on the error inside its own except block passes when the
call stops raising, because nothing runs the handler. That is the exact case
the test exists to catch, so the regression lands green.

Rewrites all 111 such blocks into pytest.raises, which fails when the call
succeeds, and selects PT017 in ruff-tests.toml so no new one lands.
2026-08-21 13:35:08 -07:00
tin-berri
6a75bbdddd
fix(mcp): deny the interactive dcr_bridge authorize for a user without server access (#37865)
The dcr_bridge oauth_delegate connect flow completed for a signed-in user
with no litellm-side grant to the target server: every leg returned 200,
the DCR client showed connected, and tools/list then fail-closed to an
empty list with the upstream never contacted (#36358). The authorize leg
now admits the user the way MCP egress will (same reload_admitted_user
constructor, same get_allowed_mcp_servers resolver) and refuses with an
RFC 6749 access_denied redirect naming the remedy, before any upstream
OAuth runs or an envelope is minted. Availability faults (5xx) propagate;
unknown or deactivated users deny fail-closed

Promotes MCPRequestHandler reload_admitted_user to public: it already had
a cross-module consumer in ui_session_utils, and this gate adds a second,
so the private name no longer reflected its use. Ratchets the freed
reportPrivateUsage budget headroom down
2026-08-21 12:27:00 -07:00
Yuneng Jiang
13d4074492
test(mcp): retire the last file of the dead tests/litellm mirror
tests/litellm/ was a second mirror beside tests/test_litellm/ that no workflow,
Makefile target, or CircleCI job ever named. Its other 33 files were reconciled
during August 2026; this one stayed behind under a ci-coverage-allowlist entry
asking a later pass to decide which of its five orphan behaviours still hold.

They no longer hold as written: 25 of its 32 cases fail against today's code,
because the file froze on the day it stopped being collected and the endpoints
kept moving. Three of the five are already covered by the live twin, and better.
test_get_request_base_url_xff_trust_gate parametrizes the trust gate in both
directions, including the exact untrusted-caller case the orphan asserted, and
the standard and legacy protected-resource shapes are both exercised through
use_standard_pattern.

The other two were the only tests anywhere for validate_trusted_redirect_uri
under that same gate, so they are ported rather than dropped, rebuilt on the
live file's request-mock conventions. Both directions are load-bearing: forcing
is_request_from_trusted_proxy to True fails the untrusted case, forcing it to
False fails the trusted one.

313 tests pass in the live file, up from 311. Dropping the dead file clears one
zero-assert TQ001 violation, so its ceiling ratchets down with it.
2026-08-21 04:41:21 -07:00
ryan-crabbe-berri
b76def0e5d
test: require a match= on broad pytest.raises, and drop duplicate parametrize cases (#37769)
`pytest.raises(Exception)` with no `match=` passes on any error that broad. A
TypeError from a refactor, a botched fixture, an import that moved: all of them
read as the rejection the test claims to police, so the test goes green for the
wrong reason and stays green after the behaviour it guards is gone.

PT011 closes that gap for the 317 sites B017 could not reach, because B017 only
fires on a single-statement body with no `as e` binding. Each pattern here is the
message the code actually raised, recorded by running the sites under a plugin
that logged the concrete type and text per call site, so the assertions describe
observed behaviour rather than a guess. Where a site raises more than one message
across its parametrize cases, the pattern is an alternation of what was seen;
where the exception carries an empty `str()` and puts the text on `.message`, the
site keeps a narrow `noqa` with the reason.

PT014 removes four parametrize cases that were listed twice. The duplicate re-runs
an assertion that already passed, and it usually marks a case someone meant to
vary and forgot to edit.
2026-08-20 20:24:49 -07:00
ryan-crabbe-berri
680bcfd8aa
test(lint): ban blind pytest.raises(Exception) with ruff B017 (#37731)
* test(lint): ban blind pytest.raises(Exception) with ruff B017

A bare pytest.raises(Exception) accepts whatever the body throws. The TypeError
a refactor introduces satisfies it exactly as well as the rejection the test was
written for, so the crash reads as a pass and the test never goes red.

All 111 existing sites are narrowed here. A runtime probe recorded the concrete
exception each one actually catches, and each site now names that type. Where
the code under test genuinely raises a bare Exception, the site pins a stable
slice of the message with match= instead.

Two sites tell on themselves. The shared responses-API cancel test raises
"custom_llm_provider is required but passed as None" rather than talking to a
provider at all, because cancel_responses takes a provider, not a model. And
test_bedrock_guardrails_with_streaming was the only test in its file still
passing without AWS credentials, because the NoCredentialsError boto3 raised
long before the guardrail ran satisfied the blind raises.

* fix(test): widen the openai batch-dispatch assertion to OpenAIError

The narrowed NotFoundError only holds where OPENAI_API_KEY is set. Without one
the SDK raises OpenAIError while building the client, long before any 404, so CI
went red. OpenAIError covers both and still rejects a TypeError from a refactor.
2026-08-20 18:09:42 -07:00
Yassin Kortam
f3639a6fb3
fix(mcp): let a salt-key-orphaned OAuth credential be replaced by re-authorization (#37672)
store_user_oauth_credential refused to overwrite any existing row that did not
decode as an OAuth2 payload, which conflated two states: a live BYOK secret that
reads back as plaintext, and ciphertext written under a LITELLM_SALT_KEY the proxy
no longer holds. The second is unrecoverable by any caller, so refusing preserved
nothing and instead wedged the user out of the OAuth flow permanently, since
re-authorizing is their only recovery.

The guard now raises only when the existing value is genuinely readable. An
undecryptable row is logged and replaced by the newly authorized token.

Both read paths were equally silent: get_user_oauth_credential and
list_user_oauth_credentials (which backs the bulk prefetch) each dropped an
undecryptable row indistinguishably from "user never authorized", so an operator
saw an upstream 401 and no hint that a credential had failed to decrypt. Both now
warn with the user and server ids, never the stored value.
2026-08-20 14:11:17 -07:00
ryan-crabbe-berri
4af59d7c6e
ci: lint the test tree for undefined names and fix all 30 (#37671)
ruff.toml excludes tests/* from `ruff check`, so nothing has ever checked the
test tree for names that do not exist. That matters more in tests than in
product code: a NameError inside a test whose body is wrapped in
`except Exception: pass` is swallowed, and the test reports green forever.

Adds ruff-tests.toml selecting F821 alone, wired into the lint workflow and
`make lint-ruff`, and clears every existing violation:

- 4 tests interpolated an unbound `e` into a `pytest.fail` message reached only
  on the failure path, so the NameError, not the assertion, is what ran.
  test_llm_guard_error_raising is the worst: it passes today with content
  safety disabled entirely. It now asserts the 400 and its detail body.
- 5 sites construct BaseExceptionGroup, a 3.11 builtin, in a tree that still
  supports 3.10. Guarded behind the exceptiongroup backport that anyio already
  pulls in below 3.11.
- 9 missing imports (json, openai, Any, Final, HTTPException), including one in
  a helper that catches HTTPException by a name it never imported, so the
  challenge path it exists to detect raises NameError instead.
- 5 annotations naming types imported inside the function body, hoisted to
  module scope or TYPE_CHECKING.
- 2 blocks of dead code: everything after a pytest.fail in
  test_claude_agent_sdk, and an unused helper in test_end_users calling a
  function defined in a different module.
- 1 error-path f-string in the router-settings doc test that masked the real
  FileNotFoundError behind a NameError.

Only F821 for now. Widening the select list means ratcheting thousands of
pre-existing findings, so rules go in one at a time with their violations
already fixed.
2026-08-20 13:30:34 -07:00
mateo-berri
238609ea5d fix(mcp): answer 503 when a refresh-token burn cannot be recorded
revoke_refresh_token discarded the single-use claim result, so a revocation that arrived while Redis was unreachable answered 200 and left the refresh token live. The token endpoint reported the same outage as invalid_grant "already used". The guard now reports first, replayed, or unavailable, and both endpoints answer 503 temporarily_unavailable for an outage (RFC 7009 section 2.2.1, RFC 6749 section 5.2), which the CLI surfaces as a one-line warning while keeping the key it has
2026-08-20 07:56:38 -07:00
mateo-berri
17455f4086 fix(proxy): refuse a teamless native-client grant for a user who has teams
The consent page offers the team picker, but a form posted without a team
sealed a teamless grant and the token endpoint minted an unscoped
credential for a team member, escaping the team attribution classic lite
login always applies. The minter now refuses such a grant on redemption
and refresh alike; memberships whose team rows are gone still count as no
team so they cannot lock a user out
2026-08-20 05:37:35 -07:00
mateo-berri
2c691d3820 feat(proxy): native CLI login with OAuth authorization code + PKCE
The proxy's OAuth authorization server (dynamic registration, PKCE S256,
loopback redirects, single-use codes, refresh rotation) gains a proxy-API
audience: /authorize?resource=<proxy origin> renders a consent page with
team selection and /token mints the same per-user credential lite login
mints, so a native CLI can sign a user in through the system browser and
call /v1/* with user and team attribution. Adds GET /.well-known/litellm-cli-auth
as the versioned discovery contract for non-Python clients, POST /revoke
(RFC 7009) for logout, and lite login --pkce, lite logout, and
lite auth print-token on the CLI side. Proxy-API grants only ever redirect
to a loopback address and the server never picks a team on the user's behalf.

Fixes #37332
2026-08-20 04:10:10 -07:00
tin-berri
0de60a2ff2
fix(mcp): stop reporting failed OpenAPI tool calls as successes (#37496)
An OpenAPI-backed MCP tool whose upstream answered 401 came back as a
successful tool result carrying the upstream's rejection as its content, so a
caller saw {"error":"invalid_token"} presented as data and the gateway recorded
the request in its own spend log as call_mcp_tool | success.

Three layers each erased the outcome. The request function returned
response.text whatever the status, _handle_local_mcp_tool caught every exception
and returned it as ordinary TextContent, and both dispatch sites then stamped
isError=False unconditionally. Fixing only the first, which is the obvious fix,
changes nothing, because the two above it still map failure onto the
success-shaped value.

The status is now classified where the response is held: a 401 becomes
MCPUpstreamAuthError so the caller is told to re-authenticate, and every other
non-2xx becomes MCPOpenApiUpstreamError, which carries the status and drops the
upstream body rather than serving it as tool content. _handle_local_mcp_tool no
longer swallows, and the call_tool arm keeps the auth error's type. Nothing new
renders these: call_mcp_tool and call_tool_rest_api already turn them into an
isError result naming the status and into a real 401 with WWW-Authenticate, and
the OpenAPI path simply never reached them.

The result is now byte-identical to the regular MCP path for the same failure.
2026-08-19 14:26:02 -07:00
tin-berri
da7a10ebbd
fix(mcp): forward the per-server auth header on OpenAPI tool calls (#37410)
Both OpenAPI dispatch arms sourced the upstream credential only from the
deprecated global / BYOK mcp_auth_header and never from mcp_server_auth_headers,
so x-mcp-{alias}-authorization was silently dropped on spec_path servers and the
upstream API received no Authorization at all. The managed path already resolves
it through lookup_mcp_server_auth_in_headers, so the two had drifted.

_resolve_openapi_tool_auth now owns that resolution for both arms. A per-server
value is already a complete header value and is forwarded verbatim, while a BYOK
credential keeps its auth-type prefix, so the two are never conflated into
"Bearer Bearer <token>". The resolved credential is also handed to
resolve_openapi_upstream_auth, whose passthrough arm reads it through
_passthrough_token_from_mcp_auth_header and outranks the ContextVar.

server.py loses its inlined copy of the forwarded-header logic along with its
mcp_server is None guards, which are unreachable after the 503 raised above them.

Credit to the earlier analysis and approach in #33349, which this supersedes
against the current v2 credential resolver.
2026-08-19 11:15:19 -07:00
tin-berri
f6eaca9069
fix(mcp): serve token-forwarding servers when oauth discovery fails (#37399)
true_passthrough and oauth_delegate forward the caller's own bearer and mint
nothing, so their sessions consume no discovered OAuth endpoint. The discovery
completeness gate still failed them closed with a 503 raised before the upstream
was ever contacted, which the tools/list fan-out swallowed into HTTP 200 with an
empty tool list. Any upstream that publishes no RFC 9728 metadata, including
every OpenAPI-backed internal API, was permanently unusable.

A failed discovery is now fatal only to servers whose OAuth flow the gateway
runs itself. Discovery still runs for the forwarding modes, so /authorize,
/token and /register keep resolving their endpoints, and each keeps its own 400
when they are missing.

MCPServer.is_client_forwarded_token now owns the mode pair that five call sites
spelled inline, one of which had already named it is_client_forwarded_mode.
2026-08-19 01:32:41 +00:00
Mateo Wang
704cc41f28
Merge pull request #37388 from BerriAI/litellm_lit_5718_mcp_tool_bound_to_server
fix(mcp): bind tool existence check to the selected server
2026-08-18 18:22:00 -07:00
mateo-berri
9018a95037 test(mcp): build fixture mapping state without in-place mutation 2026-08-18 17:35:40 -07:00
devin-ai-integration[bot]
3f15dc3287
fix(mcp): attach per-user BYOK credential when listing tools for non-oauth2 auth types (#34787)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-18 17:10:00 -07:00
mateo-berri
a30e1f6e3d fix(mcp): bind tool existence check to the selected server 2026-08-18 16:41:40 -07:00
Mateo Wang
4fd7a73ef5
Merge pull request #36599 from daniel-meismer-zocdoc/bugfix/mcp-oauth-startup-discovery
fix(mcp): oauth discovery must not cause outages
2026-08-17 22:32:00 -07:00
yucheng-berri
7984ae4636
fix(mcp): scope authorization server issuer for named MCP servers (#37204)
* fix(mcp): scope authorization server issuer

Generated with AI

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(mcp): keep the bare-origin issuer when no server was named

The scoped issuer must key off whether the request actually carried a server
name. _build_oauth_authorization_server_response rebinds mcp_server_name when
root discovery resolves the single configured OAuth2 server, so gating on the
rebound value also scoped /.well-known/openid-configuration, whose document is
served from the bare origin and whose issuer must stay the bare origin

Adds the named-server regression test for the reported mismatch, restores the
bare-origin assertion, and covers the OIDC document

* test(mcp): type the delegate_auth_to_upstream helper parameter

* refactor(mcp): bind the discovery issuer to a local before building the response

---------

Co-authored-by: Irosh <15094153+irosh-colombage-ZocDoc2@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-08-17 15:06:12 -07:00
Mateo Wang
e8154c3e4c
Revert "fix(mcp): scope authorization server issuer" (#37220) 2026-08-17 15:06:01 -07:00