Skip to content

fix(mcp): gate OAuth authorize/token/register/discovery on auth_type=oauth2 - #31736

Merged
tin-berri merged 3 commits into
litellm_internal_stagingfrom
litellm_mcp_oauth_auth_type_gate
Jul 2, 2026
Merged

fix(mcp): gate OAuth authorize/token/register/discovery on auth_type=oauth2#31736
tin-berri merged 3 commits into
litellm_internal_stagingfrom
litellm_mcp_oauth_auth_type_gate

Conversation

@tin-berri

@tin-berri tin-berri commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Internally reported MCP bug. When a LiteLLM-hosted MCP server is configured with auth_type=none (access-group gated, no per-user credential), driving it through the MCP OAuth endpoints returns a misleading client_id is required error, and the .well-known discovery docs advertise an OAuth flow that cannot succeed. A none-auth server has no client_id and no authorization URL; it is reached purely by access-group membership, so it should never enter an authorization-code flow

Linear ticket

Reported via internal bug report; no Linear ticket

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Screenshots / Proof of Fix

A/B on a live proxy (localhost:4010) backed by Postgres, with an access-group gated auth_type=none server (access_group_none) and, for the regression check, an oauth2 server (github_oauth). Baseline is litellm_internal_staging at this branch's base commit 59f51b2d72, checked out into the working tree; fixed is this branch's HEAD. Same config, same DB, same commands

The exact repro from the report (note the literal redirect_uri=http://localhost:1/cb and no client_id)

Baseline (litellm_internal_staging): the none-auth server dead-ends on the misleading error and is advertised as OAuth-protected

$ curl -s "$LITELLM_URL/access_group_none/authorize?redirect_uri=http://localhost:1/cb&state=x"
{"detail":{"error":"client_id is required but was not supplied and is not stored on the MCP server record. Provide client_id as a query parameter or configure it on the server."}}
HTTP 400

$ curl -s "$LITELLM_URL/.well-known/oauth-protected-resource/mcp/access_group_none"
{"authorization_servers":["http://127.0.0.1:4010/access_group_none"],"resource":"http://127.0.0.1:4010/mcp/access_group_none","scopes_supported":[]}
HTTP 200

$ curl -s "$LITELLM_URL/.well-known/oauth-authorization-server/access_group_none"
{"issuer":"http://127.0.0.1:4010","authorization_endpoint":"http://127.0.0.1:4010/access_group_none/authorize","token_endpoint":"http://127.0.0.1:4010/access_group_none/token","response_types_supported":["code"],"scopes_supported":[],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"token_endpoint_auth_methods_supported":["client_secret_post"],"registration_endpoint":"http://127.0.0.1:4010/access_group_none/register"}
HTTP 200

Fixed (this branch), identical commands: an accurate 400 on authorize and a 404 on discovery

$ curl -s "$LITELLM_URL/access_group_none/authorize?redirect_uri=http://localhost:1/cb&state=x"
{"detail":{"error":"server_not_oauth2","message":"MCP server 'access_group_none' does not use OAuth (auth_type=MCPAuth.none); the gateway only exposes the OAuth client_id, authorize, token, and register flow for oauth2 servers. This server is reached using its configured auth_type, so no OAuth client registration or authorization is required"}}
HTTP 400

$ curl -s "$LITELLM_URL/.well-known/oauth-protected-resource/mcp/access_group_none"
{"detail":"MCP server 'access_group_none' is not an OAuth-protected resource"}
HTTP 404

$ curl -s "$LITELLM_URL/.well-known/oauth-authorization-server/access_group_none"
{"detail":"MCP server 'access_group_none' is not an OAuth authorization server"}
HTTP 404

The token and register endpoints on the none-auth server return the same accurate 400

$ curl -s -X POST "$LITELLM_URL/access_group_none/token" -d grant_type=authorization_code -d code=x -d client_id=foo
{"detail":{"error":"server_not_oauth2","message":"MCP server 'access_group_none' does not use OAuth (auth_type=MCPAuth.none); ..."}}
HTTP 400

$ curl -s -X POST "$LITELLM_URL/access_group_none/register" -H 'Content-Type: application/json' -d '{"client_name":"x"}'
{"detail":{"error":"server_not_oauth2","message":"MCP server 'access_group_none' does not use OAuth (auth_type=MCPAuth.none); ..."}}
HTTP 400

An unknown server name returns the same 404 as the non-oauth2 server, so the well-known paths cannot be used to enumerate non-OAuth server names

$ curl -s "$LITELLM_URL/.well-known/oauth-authorization-server/does_not_exist"
{"detail":"MCP server 'does_not_exist' is not an OAuth authorization server"}
HTTP 404

$ curl -s "$LITELLM_URL/.well-known/oauth-protected-resource/mcp/does_not_exist"
{"detail":"MCP server 'does_not_exist' is not an OAuth-protected resource"}
HTTP 404

No regression on the oauth2 server or root discovery; authorize still redirects, discovery and registration still serve metadata, and root (unnamed) discovery still returns generic gateway metadata

$ curl -s -o /dev/null -w 'HTTP %{http_code} -> %{redirect_url}\n' "$LITELLM_URL/github_oauth/authorize?redirect_uri=http://localhost:8765/callback&state=xyz"
HTTP 307 -> https://github.com/login/oauth/authorize?client_id=Iv1.demo_client_id&redirect_uri=...&response_type=code&scope=read%3Auser

$ curl -s "$LITELLM_URL/.well-known/oauth-protected-resource/mcp/github_oauth"
{"authorization_servers":["http://127.0.0.1:4010/github_oauth"],"resource":"http://127.0.0.1:4010/mcp/github_oauth","scopes_supported":["read:user"]}
HTTP 200

$ curl -s -X POST "$LITELLM_URL/github_oauth/register" -H 'Content-Type: application/json' -d '{"client_name":"demo"}'
{"client_id":"github_oauth","client_secret":"dummy","redirect_uris":["http://127.0.0.1:4010/callback"]}
HTTP 200

$ curl -s "$LITELLM_URL/.well-known/oauth-authorization-server"
{"issuer":"http://127.0.0.1:4010","authorization_endpoint":"http://127.0.0.1:4010/github_oauth/authorize","token_endpoint":"http://127.0.0.1:4010/github_oauth/token", ... ,"registration_endpoint":"http://127.0.0.1:4010/github_oauth/register"}
HTTP 200

Type

🐛 Bug Fix

Changes

The gateway only acts as an OAuth authorization server for auth_type=oauth2 MCP servers. Every other auth type, notably none (access-group gated), has no client_id and no authorization URL and reaches its upstream by another mechanism, so it must not be driven through the gateway's client_id/authorize/token/register flow or advertised in discovery

authorize() checked client_id before it checked auth_type, so a none-auth server raised client_id is required even though the accurate reason is that it does not use OAuth. The .well-known builders never gated on auth_type, so _build_oauth_protected_resource_response always returned authorization_servers and _build_oauth_authorization_server_response always returned authorization_endpoint / token_endpoint / registration_endpoint, regardless of auth type

This adds a shared auth_type guard before the client_id check on the authorize, token and register paths (so the accurate reason wins over the credential error), and a discovery guard on the protected-resource and authorization-server paths. The protected-resource guard is placed after the OAuth pass-through branch so genuine pass-through servers (auth_type=none with oauth_passthrough) keep proxying their upstream metadata rather than being 404'd. The same premature client_id check existed on the internal UI OAuth endpoints (/server/oauth/{server_id}/authorize and .../token), so those are gated too. The guards fire only when auth_type != oauth2, so existing oauth2 servers are unaffected

The discovery guard returns 200 only when a named request resolves to an oauth2 server. A named server that is unknown (or hidden from the caller) and one that exists but is non-oauth2 both return the same 404, which avoids serving a broken metadata document for a typo'd name and prevents using the unauthenticated well-known paths to enumerate non-OAuth server names. Root (unnamed) discovery still returns generic gateway metadata

The non-oauth2 400 message describes the actual constraint (the gateway runs the OAuth flow only for oauth2 servers; the server is reached using its configured auth_type) so it stays accurate across every non-oauth2 type, including oauth2_token_exchange, rather than asserting access-group semantics that only apply to auth_type=none

Coverage note: != oauth2 intentionally also excludes oauth2_token_exchange and every other non-redirect auth type, which is correct since none of them use the authorization-code flow exposed by these endpoints

Tests extend the existing mapped files. The discoverable-endpoint tests assert the accurate 400/404 on a none-auth server for authorize, token, register and both discovery shapes, that an unknown server name returns 404 from both discovery shapes, and that a pass-through none-auth server still proxies upstream metadata (so the protected-resource guard cannot be moved before the pass-through branch). The management-endpoint tests assert mcp_authorize and mcp_token reject a none-auth server before the client_id check and never delegate. Each new test fails on the unfixed code and passes with the fix

…oauth2

A non-oauth2 MCP server (notably auth_type=none, access-group gated) has no
client_id and no authorization URL, yet the gateway OAuth endpoints did not
check auth_type. authorize() raised "client_id is required" before the
auth_type was ever examined, and the .well-known discovery builders always
advertised authorization_servers / authorization_endpoint / token_endpoint /
registration_endpoint, so spec-compliant MCP clients were pointed at an OAuth
flow that can never succeed.

Add an auth_type != oauth2 guard to the authorize, token, register,
protected-resource and authorization-server paths (covering the internal UI
OAuth endpoints too). The discovery guard sits after the OAuth pass-through
branch so genuine pass-through servers keep proxying their upstream metadata.
oauth2 servers are unaffected.
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@codspeed-hq

codspeed-hq Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 30 untouched benchmarks


Comparing litellm_mcp_oauth_auth_type_gate (6d20576) with litellm_internal_staging (be4d0d8)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Gates the MCP OAuth authorize, token, register, and discovery endpoints behind an auth_type == MCPAuth.oauth2 check, so that non-oauth2 servers (e.g. auth_type=none) are no longer driven through the gateway's OAuth flow or falsely advertised as OAuth-protected in .well-known discovery documents.

  • Two new guard helpers (_raise_if_not_oauth2, _raise_unless_oauth2_discovery_server) are inserted at the correct call-site depth — before the client_id check in authorize/token/register, and after the pass-through branch in protected-resource discovery so that oauth_passthrough=True servers keep proxying upstream metadata.
  • Discovery endpoints now return the same 404 for both unknown server names and non-oauth2 servers, preventing the well-known paths from being used to enumerate non-OAuth server names.
  • Tests added for all new rejection paths; existing success-path tests correctly updated to set auth_type=MCPAuth.oauth2 so the guard passes.

Confidence Score: 5/5

Safe to merge — the changes are narrowly scoped guards that fire only when auth_type != oauth2; oauth2 servers are completely unaffected, and the pass-through branch is preserved.

The auth_type guards are logically correct, the placement of the protected-resource guard (after the pass-through branch) is intentional and explicitly regression-tested, discovery now correctly returns 404 for both unknown and non-oauth2 server names, and all test modifications add specificity rather than weakening coverage.

No files require special attention.

Important Files Changed

Filename Overview
litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py Adds _raise_if_not_oauth2 and _raise_unless_oauth2_discovery_server guards; correctly placed after pass-through branch in protected-resource discovery and before client_id checks in authorize/token/register flows. Logic is sound.
litellm/proxy/management_endpoints/mcp_management_endpoints.py Imports _raise_if_not_oauth2 and inserts it immediately after _get_cached_temporary_mcp_server_or_404 in mcp_authorize and mcp_token handlers; correct placement and import scope.
tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py Eight new tests added: authorize/token/register reject non-oauth2 servers; both discovery shapes 404 on non-oauth2 and unknown server names; pass-through guard regression is explicitly covered. All mock-only, no network calls.
tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py Two new rejection tests for mcp_authorize and mcp_token; existing success-path tests correctly updated to set auth_type=MCPAuth.oauth2 so the guard passes — changes are accurate, not coverage-weakening.

Reviews (5): Last reviewed commit: "Apply suggestions from code review" | Re-trigger Greptile

Comment thread litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py
Comment thread litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py Outdated
@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Comment thread litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py Outdated
@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR gates the MCP gateway's OAuth authorize/token/register/discovery endpoints on auth_type=oauth2, so that access-group-gated (auth_type=none) servers no longer enter the authorization-code flow or appear in OAuth discovery metadata.

  • Adds _raise_if_not_oauth2 (400) and _raise_if_discovery_server_not_oauth2 (404) helpers and wires them into authorize, exchange_token_with_server, register_client_with_server, both discovery builders, and the management-endpoint mcp_authorize/mcp_token handlers.
  • The pass-through guard in _build_oauth_protected_resource_response is deliberately placed after the existing oauth_passthrough branch, preserving upstream-metadata proxying for pass-through servers while blocking plain none-auth servers from the discovery flow.
  • Tests add six new rejection cases (authorize, token, register, both discovery shapes, and a passthrough regression guard) plus update four existing management-endpoint tests to set auth_type=MCPAuth.oauth2 explicitly.

Confidence Score: 4/5

Safe to merge; the fix correctly blocks non-oauth2 servers from OAuth flows and includes meaningful test coverage for the key paths.

The core guard logic is correct and placed at the right points. The two observations are minor: the error message body inaccurately describes token-exchange servers as 'access-group gated', and the mcp_register management endpoint is guarded only indirectly (inside the delegate) rather than at the handler level like its siblings. Neither causes incorrect runtime behavior today.

mcp_management_endpoints.py — the mcp_register handler lacks a direct guard call and a dedicated rejection test, in contrast to mcp_authorize and mcp_token which both received explicit guards and tests.

Important Files Changed

Filename Overview
litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py Adds _raise_if_not_oauth2 and _raise_if_discovery_server_not_oauth2 guards; wires them into authorize, exchange_token_with_server, register_client_with_server, and both discovery builders. Guard placement relative to the pass-through branch is correct. Error message body is inaccurate for non-none auth types like oauth2_token_exchange.
litellm/proxy/management_endpoints/mcp_management_endpoints.py mcp_authorize and mcp_token now have _raise_if_not_oauth2 directly in the handler and matching tests. mcp_register relies on the guard inside register_client_with_server — functionally correct but inconsistent with the sibling endpoints and lacks a dedicated rejection test.
tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py Adds six new tests covering authorize, token, register, and both discovery shapes for a none-auth server, plus a regression guard for the passthrough ordering. Existing tests updated to set auth_type=MCPAuth.oauth2 where appropriate. All tests use mocks and make no real network calls.
tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py Adds rejection tests for mcp_authorize and mcp_token with none-auth servers; updates existing tests to set auth_type=oauth2. No rejection test for mcp_register.

Comments Outside Diff (1)

  1. litellm/proxy/management_endpoints/mcp_management_endpoints.py, line 1703-1720 (link)

    P2 mcp_register management endpoint has no direct guard and no rejection test

    mcp_authorize and mcp_token both received an explicit _raise_if_not_oauth2 call directly in the management endpoint handler, and each got a corresponding rejection test. mcp_register relies on the guard embedded inside register_client_with_server, meaning the defence is present but is one level deeper and invisible at the call-site. For consistency with the two sibling endpoints — and to keep the test test_mcp_management_endpoints.py aligned with how mcp_authorize/mcp_token are tested — adding _raise_if_not_oauth2(mcp_server) immediately after the server fetch and a matching rejection test would round out the coverage.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (2): Last reviewed commit: "fix(mcp): gate OAuth authorize/token/reg..." | Re-trigger Greptile

@veria-ai

veria-ai Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

Comment thread litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py
… close enumeration oracle

Address review feedback on the auth_type gate.

The 400 message no longer claims access is governed by access groups, which is
only true for auth_type=none; it now states that the gateway runs the OAuth
client_id/authorize/token/register flow only for oauth2 servers and that the
server is reached using its configured auth_type, which is accurate for every
non-oauth2 type (api_key, oauth2_token_exchange, etc.).

The discovery gate previously 404'd a named non-oauth2 server but still returned
200 metadata for an unknown name, which both serves a broken document for a typo
and lets an unauthenticated caller enumerate non-OAuth server names by comparing
404 vs 200. A named discovery request now returns 200 only when it resolves to an
oauth2 server; unknown (or hidden) and non-oauth2 names return the same 404. Root
discovery and pass-through servers are unaffected.
@tin-berri

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Addressed in 0538e52

Error message over-generalizing to access-group servers (P2): correct. The 400 message no longer asserts access-group semantics. It now states that the gateway exposes the OAuth client_id/authorize/token/register flow only for oauth2 servers and that the server is reached using its configured auth_type, which stays accurate for every non-oauth2 type including oauth2_token_exchange. The auth_type=... value is still interpolated for the concrete reason

Unknown server name silently passes the discovery guard / name enumeration (P2 + Veria): correct, and this was a real asymmetry the first commit introduced (named non-oauth2 returned 404 while an unknown name still returned 200). The discovery guard now returns 200 only when a named request resolves to an oauth2 server; an unknown (or caller-hidden) name and a non-oauth2 name return the same 404, so the well-known paths no longer serve a broken metadata document for a typo and cannot be used to enumerate non-OAuth server names. Root (unnamed) discovery and pass-through servers are unaffected. Covered by two new tests asserting unknown names 404 from both discovery shapes

@greptileai

@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Both fixes verified — the implementation in 0538e52 is correct.

Error message (_raise_if_not_oauth2): The message no longer asserts access-group semantics. It now reads "the gateway only exposes the OAuth client_id, authorize, token, and register flow for oauth2 servers. This server is reached using its configured auth_type" — which is accurate for every non-oauth2 type including oauth2_token_exchange, api_key, etc.

Discovery enumeration oracle (_raise_unless_oauth2_discovery_server): The condition mcp_server is not None and mcp_server.auth_type == MCPAuth.oauth2 correctly collapses unknown and non-oauth2 names into the same 404. An unauthenticated caller can no longer distinguish "server doesn't exist" from "server exists but isn't oauth2" via the well-known paths. Root discovery and pass-through servers are unaffected.

Guard placement in _build_oauth_protected_resource_response: The _raise_unless_oauth2_discovery_server call sits after the fetch_upstream_oauth_protected_resource pass-through branch (line ~1106 vs. ~1063), so a none-auth + oauth_passthrough=True server still proxies upstream metadata. The test_oauth_protected_resource_passthrough_none_auth_not_404 regression test locks this ordering in.

Existing tests: Explicitly setting server.auth_type = MCPAuth.oauth2 on the pre-existing happy-path tests is the right call — those tests were implicitly relying on whatever the mock default happened to produce, which was fragile.

The fix is complete and well-covered. LGTM.

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri mateo-berri left a comment

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.

LGTM; thanks!

@tin-berri
tin-berri merged commit c370503 into litellm_internal_staging Jul 2, 2026
126 checks passed
@tin-berri
tin-berri deleted the litellm_mcp_oauth_auth_type_gate branch July 2, 2026 17:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants